security: harden the client, config writes, git auth scope, and human outputUnverified
Findings from a security review of the 0.1.0 code, each with a test: - HTTP: redirects are followed only on the same host and never https→http, so the bearer token cannot be carried to another origin (Go keeps Authorization for subdomains and same-host downgrades); response bodies are capped at 128 MiB so a hostile host cannot exhaust memory. - Config: saves are atomic (temp file + rename) and always 0600, so a crash cannot truncate the token file and a world-readable file left by another tool is fixed on the next save instead of inherited. - repo clone --protocol https: the token's extraHeader is scoped to the clone origin (http.<origin>/.extraHeader) so git never sends it to a redirect target or a submodule on another host. - Output: server-provided titles, bodies, and names are stripped of terminal control sequences before human display; tables go through a filtering writer. JSON and run logs are untouched. - gosec/staticcheck clean: tabwriter Flush errors are handled, the dead ctx helper is gone, and the two shell-outs plus the config read carry #nosec annotations with their justification in place. - go.mod pins toolchain go1.26.8: govulncheck reports stdlib CVEs fixed in 1.26.6 (net/http, crypto/tls, net/url, encoding/asn1) against older patch releases; the 0.1.0 release was built with 1.26.8 and is not affected, but a local 1.26.4 build would have been. Adds .github/workflows/security.yml (push, PR, weekly): go vet, go mod verify + tidy check, govulncheck, gosec, staticcheck, and a clean-tree release build, with scanner versions pinned. Adds SECURITY.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FgkcT3UPxwxukBKojB5eg4
b7e1d1f parent: fe71cc6 added
.github/workflows/security.yml +82 -0 | new file mode 100644 | ||
| @@ -0,0 +1,82 @@ | ||
| 1 | +name: Security | |
| 2 | + | |
| 3 | +# Static and dependency security checks, separate from the fast CI gate so a | |
| 4 | +# scanner outage or a new advisory cannot block an ordinary build, and so the | |
| 5 | +# weekly run keeps checking main against new advisories between commits. | |
| 6 | +# | |
| 7 | +# NOTE: no workflow_dispatch here — the rickub dispatch API fires *every* | |
| 8 | +# dispatchable workflow on a ref, so only release.yml may declare it. See | |
| 9 | +# RELEASING.md. | |
| 10 | +on: | |
| 11 | + push: | |
| 12 | + branches: | |
| 13 | + - main | |
| 14 | + pull_request: | |
| 15 | + schedule: | |
| 16 | + # Mondays 06:00 UTC: catch advisories published since the last push. | |
| 17 | + - cron: "0 6 * * 1" | |
| 18 | + | |
| 19 | +permissions: | |
| 20 | + contents: read | |
| 21 | + | |
| 22 | +concurrency: | |
| 23 | + group: security-${{ github.ref }} | |
| 24 | + cancel-in-progress: true | |
| 25 | + | |
| 26 | +# Scanner versions are pinned; bumping one is a reviewed change, not a surprise. | |
| 27 | +env: | |
| 28 | + GOVULNCHECK_VERSION: v1.7.0 | |
| 29 | + GOSEC_VERSION: v2.29.0 | |
| 30 | + STATICCHECK_VERSION: v0.8.1 | |
| 31 | + | |
| 32 | +jobs: | |
| 33 | + scan: | |
| 34 | + name: vet / vuln / gosec / staticcheck / modules | |
| 35 | + runs-on: ubuntu-latest | |
| 36 | + steps: | |
| 37 | + - name: Checkout | |
| 38 | + uses: actions/checkout@v4 | |
| 39 | + | |
| 40 | + - name: Set up Go | |
| 41 | + uses: actions/setup-go@v5 | |
| 42 | + with: | |
| 43 | + go-version-file: go.mod | |
| 44 | + cache: true | |
| 45 | + | |
| 46 | + - name: go vet | |
| 47 | + run: go vet ./... | |
| 48 | + | |
| 49 | + - name: Module integrity | |
| 50 | + # go.sum must match what the proxy serves, and go.mod/go.sum must be | |
| 51 | + # tidy: an untracked requirement is a dependency nobody reviewed. | |
| 52 | + run: | | |
| 53 | + set -euo pipefail | |
| 54 | + go mod verify | |
| 55 | + go mod tidy | |
| 56 | + if ! git diff --exit-code -- go.mod go.sum; then | |
| 57 | + echo "::error::go.mod/go.sum are not tidy; run 'go mod tidy' and commit" | |
| 58 | + exit 1 | |
| 59 | + fi | |
| 60 | + | |
| 61 | + - name: govulncheck | |
| 62 | + # Known vulnerabilities in the standard library and in every module the | |
| 63 | + # binary links, reported only where our code actually reaches them. | |
| 64 | + run: go run golang.org/x/vuln/cmd/govulncheck@${GOVULNCHECK_VERSION} ./... | |
| 65 | + | |
| 66 | + - name: gosec | |
| 67 | + # Static analysis for insecure patterns (command injection, file modes, | |
| 68 | + # unchecked errors…). Every accepted finding carries a '#nosec' with a | |
| 69 | + # reason next to the code; there is no global exclude list. | |
| 70 | + run: go run github.com/securego/gosec/v2/cmd/gosec@${GOSEC_VERSION} -quiet ./... | |
| 71 | + | |
| 72 | + - name: staticcheck | |
| 73 | + run: go run honnef.co/go/tools/cmd/staticcheck@${STATICCHECK_VERSION} ./... | |
| 74 | + | |
| 75 | + - name: Release binary is reproducible from a clean tree | |
| 76 | + # The same flags as scripts/build-dist.sh. Guards against a build that | |
| 77 | + # only works with something untracked lying around. | |
| 78 | + run: | | |
| 79 | + set -euo pipefail | |
| 80 | + git diff --quiet || { echo "::error::working tree dirty before build"; git status --short; exit 1; } | |
| 81 | + CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X rickub.com/rickub/cli/cmd.Version=security-scan" -o "${RUNNER_TEMP}/rickub" . | |
| 82 | + "${RUNNER_TEMP}/rickub" version | |
| new file mode 100644 | |||
| @@ -0,0 +1,82 @@ | |||
| 1 | +name: Security | ||
| 2 | + | ||
| 3 | +# Static and dependency security checks, separate from the fast CI gate so a | ||
| 4 | +# scanner outage or a new advisory cannot block an ordinary build, and so the | ||
| 5 | +# weekly run keeps checking main against new advisories between commits. | ||
| 6 | +# | ||
| 7 | +# NOTE: no workflow_dispatch here — the rickub dispatch API fires *every* | ||
| 8 | +# dispatchable workflow on a ref, so only release.yml may declare it. See | ||
| 9 | +# RELEASING.md. | ||
| 10 | +on: | ||
| 11 | + push: | ||
| 12 | + branches: | ||
| 13 | + - main | ||
| 14 | + pull_request: | ||
| 15 | + schedule: | ||
| 16 | + # Mondays 06:00 UTC: catch advisories published since the last push. | ||
| 17 | + - cron: "0 6 * * 1" | ||
| 18 | + | ||
| 19 | +permissions: | ||
| 20 | + contents: read | ||
| 21 | + | ||
| 22 | +concurrency: | ||
| 23 | + group: security-${{ github.ref }} | ||
| 24 | + cancel-in-progress: true | ||
| 25 | + | ||
| 26 | +# Scanner versions are pinned; bumping one is a reviewed change, not a surprise. | ||
| 27 | +env: | ||
| 28 | + GOVULNCHECK_VERSION: v1.7.0 | ||
| 29 | + GOSEC_VERSION: v2.29.0 | ||
| 30 | + STATICCHECK_VERSION: v0.8.1 | ||
| 31 | + | ||
| 32 | +jobs: | ||
| 33 | + scan: | ||
| 34 | + name: vet / vuln / gosec / staticcheck / modules | ||
| 35 | + runs-on: ubuntu-latest | ||
| 36 | + steps: | ||
| 37 | + - name: Checkout | ||
| 38 | + uses: actions/checkout@v4 | ||
| 39 | + | ||
| 40 | + - name: Set up Go | ||
| 41 | + uses: actions/setup-go@v5 | ||
| 42 | + with: | ||
| 43 | + go-version-file: go.mod | ||
| 44 | + cache: true | ||
| 45 | + | ||
| 46 | + - name: go vet | ||
| 47 | + run: go vet ./... | ||
| 48 | + | ||
| 49 | + - name: Module integrity | ||
| 50 | + # go.sum must match what the proxy serves, and go.mod/go.sum must be | ||
| 51 | + # tidy: an untracked requirement is a dependency nobody reviewed. | ||
| 52 | + run: | | ||
| 53 | + set -euo pipefail | ||
| 54 | + go mod verify | ||
| 55 | + go mod tidy | ||
| 56 | + if ! git diff --exit-code -- go.mod go.sum; then | ||
| 57 | + echo "::error::go.mod/go.sum are not tidy; run 'go mod tidy' and commit" | ||
| 58 | + exit 1 | ||
| 59 | + fi | ||
| 60 | + | ||
| 61 | + - name: govulncheck | ||
| 62 | + # Known vulnerabilities in the standard library and in every module the | ||
| 63 | + # binary links, reported only where our code actually reaches them. | ||
| 64 | + run: go run golang.org/x/vuln/cmd/govulncheck@${GOVULNCHECK_VERSION} ./... | ||
| 65 | + | ||
| 66 | + - name: gosec | ||
| 67 | + # Static analysis for insecure patterns (command injection, file modes, | ||
| 68 | + # unchecked errors…). Every accepted finding carries a '#nosec' with a | ||
| 69 | + # reason next to the code; there is no global exclude list. | ||
| 70 | + run: go run github.com/securego/gosec/v2/cmd/gosec@${GOSEC_VERSION} -quiet ./... | ||
| 71 | + | ||
| 72 | + - name: staticcheck | ||
| 73 | + run: go run honnef.co/go/tools/cmd/staticcheck@${STATICCHECK_VERSION} ./... | ||
| 74 | + | ||
| 75 | + - name: Release binary is reproducible from a clean tree | ||
| 76 | + # The same flags as scripts/build-dist.sh. Guards against a build that | ||
| 77 | + # only works with something untracked lying around. | ||
| 78 | + run: | | ||
| 79 | + set -euo pipefail | ||
| 80 | + git diff --quiet || { echo "::error::working tree dirty before build"; git status --short; exit 1; } | ||
| 81 | + CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X rickub.com/rickub/cli/cmd.Version=security-scan" -o "${RUNNER_TEMP}/rickub" . | ||
| 82 | + "${RUNNER_TEMP}/rickub" version | ||
modified
README.md +9 -0 | @@ -247,12 +247,21 @@ rickub repo list --user <you> | ||
| 247 | 247 | |
| 248 | 248 | The dev token is stored separately from your rickub.com token; both stay put. |
| 249 | 249 | |
| 250 | +## Security | |
| 251 | + | |
| 252 | +See [SECURITY.md](SECURITY.md): where the token lives and how it is bound to | |
| 253 | +its host, the redirect and body-size policy, how `git` and the browser are | |
| 254 | +invoked, and what the security workflow checks. | |
| 255 | + | |
| 250 | 256 | ## Development |
| 251 | 257 | |
| 252 | 258 | ```sh |
| 253 | 259 | go build ./... && go test ./... && go vet ./... && gofmt -l . |
| 254 | 260 | ``` |
| 255 | 261 | |
| 262 | +CI runs those on every push and pull request (`ci.yml`), and `security.yml` | |
| 263 | +adds `go mod verify`, `govulncheck`, `gosec`, and `staticcheck`. | |
| 264 | + | |
| 256 | 265 | Layout: |
| 257 | 266 | |
| 258 | 267 | ``` |
| @@ -247,12 +247,21 @@ rickub repo list --user <you> | |||
| 247 | 247 | ||
| 248 | The dev token is stored separately from your rickub.com token; both stay put. | 248 | The dev token is stored separately from your rickub.com token; both stay put. |
| 249 | 249 | ||
| 250 | +## Security | ||
| 251 | + | ||
| 252 | +See [SECURITY.md](SECURITY.md): where the token lives and how it is bound to | ||
| 253 | +its host, the redirect and body-size policy, how `git` and the browser are | ||
| 254 | +invoked, and what the security workflow checks. | ||
| 255 | + | ||
| 250 | ## Development | 256 | ## Development |
| 251 | 257 | ||
| 252 | ```sh | 258 | ```sh |
| 253 | go build ./... && go test ./... && go vet ./... && gofmt -l . | 259 | go build ./... && go test ./... && go vet ./... && gofmt -l . |
| 254 | ``` | 260 | ``` |
| 255 | 261 | ||
| 262 | +CI runs those on every push and pull request (`ci.yml`), and `security.yml` | ||
| 263 | +adds `go mod verify`, `govulncheck`, `gosec`, and `staticcheck`. | ||
| 264 | + | ||
| 256 | Layout: | 265 | Layout: |
| 257 | 266 | ||
| 258 | ``` | 267 | ``` |
added
SECURITY.md +62 -0 | new file mode 100644 | ||
| @@ -0,0 +1,62 @@ | ||
| 1 | +# Security | |
| 2 | + | |
| 3 | +## What the CLI holds | |
| 4 | + | |
| 5 | +The only secret `rickub` handles is your personal access token (PAT). | |
| 6 | + | |
| 7 | +- It is stored in `~/.config/rickub/config.yaml` (honouring `XDG_CONFIG_HOME`), | |
| 8 | + written atomically with mode `0600` in a `0700` directory. A save always | |
| 9 | + re-applies `0600`, even over a file something else made world-readable. | |
| 10 | +- A stored token is bound to the host it was minted for and is only ever sent | |
| 11 | + there. `--host` or `RICKUB_HOST` pointing elsewhere gets "no token stored | |
| 12 | + for that host", never your production credential. | |
| 13 | +- A token given explicitly (`--token`, `RICKUB_TOKEN`) is sent to whatever host | |
| 14 | + is in effect; the CLI warns before sending it over plain `http://` to a | |
| 15 | + non-loopback address. | |
| 16 | +- `auth status` prints only the non-secret `rickub_pat_` prefix. `--token` is | |
| 17 | + discouraged in the help text because arguments are visible to other | |
| 18 | + processes and end up in shell history; prefer `auth login --with-token` | |
| 19 | + (stdin) or the environment variable. | |
| 20 | + | |
| 21 | +## Network behaviour | |
| 22 | + | |
| 23 | +- Every request goes to `<host>/api/v1/…` with the token as a bearer header. | |
| 24 | +- Redirects are followed only on the same host and never from https to http; | |
| 25 | + anything else is refused, so a redirect cannot carry the token away. | |
| 26 | +- Response bodies are capped (128 MiB) so a hostile host cannot exhaust memory. | |
| 27 | +- No TLS verification is ever disabled. | |
| 28 | + | |
| 29 | +## Shelling out | |
| 30 | + | |
| 31 | +- `repo clone` runs `git` with a URL assembled from a fixed scheme and host and | |
| 32 | + the `owner/repo` you typed; anything after `--` is passed to git unchanged. | |
| 33 | + With `--protocol https` the token reaches git through its environment as a | |
| 34 | + header scoped to that one origin (`http.<origin>/.extraHeader`): never on | |
| 35 | + the command line, never written to `.git/config`, never sent to a redirect | |
| 36 | + target or another host's submodule. | |
| 37 | +- `browse` and the device login hand a URL to the platform opener only after | |
| 38 | + checking it is `http`/`https` with a host, so no other scheme handler can be | |
| 39 | + reached. | |
| 40 | +- Repo inference runs `git remote get-url origin` in the current directory. | |
| 41 | + | |
| 42 | +## Output | |
| 43 | + | |
| 44 | +Text that comes from the server (titles, bodies, names) is stripped of terminal | |
| 45 | +control sequences before it is printed for a human, so a crafted issue title | |
| 46 | +cannot repaint your terminal. `--json` output and `run logs` are passed through | |
| 47 | +untouched. | |
| 48 | + | |
| 49 | +## Continuous checks | |
| 50 | + | |
| 51 | +`.github/workflows/security.yml` runs on every push and pull request and weekly: | |
| 52 | +`go vet`, `go mod verify` + tidy check, `govulncheck` (stdlib and dependency | |
| 53 | +advisories reachable from this code), `gosec` (every accepted finding is | |
| 54 | +annotated in place with a reason), `staticcheck`, and a clean-tree release | |
| 55 | +build. `go.mod` pins the toolchain so builds do not silently use an older Go | |
| 56 | +with known CVEs. | |
| 57 | + | |
| 58 | +## Reporting | |
| 59 | + | |
| 60 | +If you find a vulnerability, please report it privately to the maintainer | |
| 61 | +(see the commit authors) rather than in a public issue; open an issue at | |
| 62 | +<https://rickub.com/rickub/cli/issues> for anything that is not sensitive. | |
| new file mode 100644 | |||
| @@ -0,0 +1,62 @@ | |||
| 1 | +# Security | ||
| 2 | + | ||
| 3 | +## What the CLI holds | ||
| 4 | + | ||
| 5 | +The only secret `rickub` handles is your personal access token (PAT). | ||
| 6 | + | ||
| 7 | +- It is stored in `~/.config/rickub/config.yaml` (honouring `XDG_CONFIG_HOME`), | ||
| 8 | + written atomically with mode `0600` in a `0700` directory. A save always | ||
| 9 | + re-applies `0600`, even over a file something else made world-readable. | ||
| 10 | +- A stored token is bound to the host it was minted for and is only ever sent | ||
| 11 | + there. `--host` or `RICKUB_HOST` pointing elsewhere gets "no token stored | ||
| 12 | + for that host", never your production credential. | ||
| 13 | +- A token given explicitly (`--token`, `RICKUB_TOKEN`) is sent to whatever host | ||
| 14 | + is in effect; the CLI warns before sending it over plain `http://` to a | ||
| 15 | + non-loopback address. | ||
| 16 | +- `auth status` prints only the non-secret `rickub_pat_` prefix. `--token` is | ||
| 17 | + discouraged in the help text because arguments are visible to other | ||
| 18 | + processes and end up in shell history; prefer `auth login --with-token` | ||
| 19 | + (stdin) or the environment variable. | ||
| 20 | + | ||
| 21 | +## Network behaviour | ||
| 22 | + | ||
| 23 | +- Every request goes to `<host>/api/v1/…` with the token as a bearer header. | ||
| 24 | +- Redirects are followed only on the same host and never from https to http; | ||
| 25 | + anything else is refused, so a redirect cannot carry the token away. | ||
| 26 | +- Response bodies are capped (128 MiB) so a hostile host cannot exhaust memory. | ||
| 27 | +- No TLS verification is ever disabled. | ||
| 28 | + | ||
| 29 | +## Shelling out | ||
| 30 | + | ||
| 31 | +- `repo clone` runs `git` with a URL assembled from a fixed scheme and host and | ||
| 32 | + the `owner/repo` you typed; anything after `--` is passed to git unchanged. | ||
| 33 | + With `--protocol https` the token reaches git through its environment as a | ||
| 34 | + header scoped to that one origin (`http.<origin>/.extraHeader`): never on | ||
| 35 | + the command line, never written to `.git/config`, never sent to a redirect | ||
| 36 | + target or another host's submodule. | ||
| 37 | +- `browse` and the device login hand a URL to the platform opener only after | ||
| 38 | + checking it is `http`/`https` with a host, so no other scheme handler can be | ||
| 39 | + reached. | ||
| 40 | +- Repo inference runs `git remote get-url origin` in the current directory. | ||
| 41 | + | ||
| 42 | +## Output | ||
| 43 | + | ||
| 44 | +Text that comes from the server (titles, bodies, names) is stripped of terminal | ||
| 45 | +control sequences before it is printed for a human, so a crafted issue title | ||
| 46 | +cannot repaint your terminal. `--json` output and `run logs` are passed through | ||
| 47 | +untouched. | ||
| 48 | + | ||
| 49 | +## Continuous checks | ||
| 50 | + | ||
| 51 | +`.github/workflows/security.yml` runs on every push and pull request and weekly: | ||
| 52 | +`go vet`, `go mod verify` + tidy check, `govulncheck` (stdlib and dependency | ||
| 53 | +advisories reachable from this code), `gosec` (every accepted finding is | ||
| 54 | +annotated in place with a reason), `staticcheck`, and a clean-tree release | ||
| 55 | +build. `go.mod` pins the toolchain so builds do not silently use an older Go | ||
| 56 | +with known CVEs. | ||
| 57 | + | ||
| 58 | +## Reporting | ||
| 59 | + | ||
| 60 | +If you find a vulnerability, please report it privately to the maintainer | ||
| 61 | +(see the commit authors) rather than in a public issue; open an issue at | ||
| 62 | +<https://rickub.com/rickub/cli/issues> for anything that is not sensitive. | ||
modified
cmd/browse.go +2 -0 | @@ -87,5 +87,7 @@ func openBrowser(url string) error { | ||
| 87 | 87 | name = "xdg-open" |
| 88 | 88 | args = []string{url} |
| 89 | 89 | } |
| 90 | + // #nosec G204 -- name is a fixed per-OS opener and url has just been checked | |
| 91 | + // to be an http(s) URL with a host, so no other scheme handler can be reached. | |
| 90 | 92 | return exec.Command(name, args...).Start() |
| 91 | 93 | } |
| @@ -87,5 +87,7 @@ func openBrowser(url string) error { | |||
| 87 | name = "xdg-open" | 87 | name = "xdg-open" |
| 88 | args = []string{url} | 88 | args = []string{url} |
| 89 | } | 89 | } |
| 90 | + // #nosec G204 -- name is a fixed per-OS opener and url has just been checked | ||
| 91 | + // to be an http(s) URL with a host, so no other scheme handler can be reached. | ||
| 90 | return exec.Command(name, args...).Start() | 92 | return exec.Command(name, args...).Start() |
| 91 | } | 93 | } |
modified
cmd/collaborator.go +1 -2 | @@ -68,8 +68,7 @@ func runCollabList(cmd *cobra.Command, args []string) error { | ||
| 68 | 68 | for _, c := range cols { |
| 69 | 69 | fmt.Fprintf(tw, "%s\t%s\t%s\n", c.Handle, c.Permission, dash(c.DisplayName)) |
| 70 | 70 | } |
| 71 | - tw.Flush() | |
| 72 | - return nil | |
| 71 | + return tw.Flush() | |
| 73 | 72 | } |
| 74 | 73 | |
| 75 | 74 | func runCollabAdd(cmd *cobra.Command, args []string) error { |
| @@ -68,8 +68,7 @@ func runCollabList(cmd *cobra.Command, args []string) error { | |||
| 68 | for _, c := range cols { | 68 | for _, c := range cols { |
| 69 | fmt.Fprintf(tw, "%s\t%s\t%s\n", c.Handle, c.Permission, dash(c.DisplayName)) | 69 | fmt.Fprintf(tw, "%s\t%s\t%s\n", c.Handle, c.Permission, dash(c.DisplayName)) |
| 70 | } | 70 | } |
| 71 | - tw.Flush() | 71 | + return tw.Flush() |
| 72 | - return nil | ||
| 73 | } | 72 | } |
| 74 | 73 | ||
| 75 | func runCollabAdd(cmd *cobra.Command, args []string) error { | 74 | func runCollabAdd(cmd *cobra.Command, args []string) error { |
modified
cmd/issue.go +7 -6 | @@ -175,7 +175,9 @@ func runIssueList(cmd *cobra.Command, _ []string) error { | ||
| 175 | 175 | } |
| 176 | 176 | fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%s\n", i.Number, i.State, dash(labels), dash(milestone), i.Title) |
| 177 | 177 | } |
| 178 | - tw.Flush() | |
| 178 | + if err := tw.Flush(); err != nil { | |
| 179 | + return err | |
| 180 | + } | |
| 179 | 181 | printPageFooter(cmd, page.Page) |
| 180 | 182 | return nil |
| 181 | 183 | } |
| @@ -205,7 +207,7 @@ func runIssueView(cmd *cobra.Command, args []string) error { | ||
| 205 | 207 | if i.Milestone != nil { |
| 206 | 208 | state += " · " + i.Milestone.Title |
| 207 | 209 | } |
| 208 | - fmt.Fprintf(out, "Issue #%d %s [%s]\n", i.Number, i.Title, state) | |
| 210 | + fmt.Fprintf(out, "Issue #%d %s [%s]\n", i.Number, clean(i.Title), state) | |
| 209 | 211 | fmt.Fprintf(out, "by %s · %s\n", dash(i.Author), humanTime(i.CreatedAt)) |
| 210 | 212 | if len(i.Labels) > 0 { |
| 211 | 213 | names := make([]string, len(i.Labels)) |
| @@ -223,10 +225,10 @@ func runIssueView(cmd *cobra.Command, args []string) error { | ||
| 223 | 225 | } |
| 224 | 226 | fmt.Fprintln(out) |
| 225 | 227 | if i.Body != "" { |
| 226 | - fmt.Fprintln(out, i.Body) | |
| 228 | + fmt.Fprintln(out, clean(i.Body)) | |
| 227 | 229 | } |
| 228 | 230 | for _, c := range i.Comments { |
| 229 | - fmt.Fprintf(out, "\n--- %s (%s) ---\n%s\n", c.Author, humanTime(c.CreatedAt), c.Body) | |
| 231 | + fmt.Fprintf(out, "\n--- %s (%s) ---\n%s\n", clean(c.Author), humanTime(c.CreatedAt), clean(c.Body)) | |
| 230 | 232 | } |
| 231 | 233 | return nil |
| 232 | 234 | } |
| @@ -449,6 +451,5 @@ func runIssueLabelsList(cmd *cobra.Command, _ []string) error { | ||
| 449 | 451 | for _, l := range labels { |
| 450 | 452 | fmt.Fprintf(tw, "%s\t#%s\n", l.Name, l.Color) |
| 451 | 453 | } |
| 452 | - tw.Flush() | |
| 453 | - return nil | |
| 454 | + return tw.Flush() | |
| 454 | 455 | } |
| @@ -175,7 +175,9 @@ func runIssueList(cmd *cobra.Command, _ []string) error { | |||
| 175 | } | 175 | } |
| 176 | fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%s\n", i.Number, i.State, dash(labels), dash(milestone), i.Title) | 176 | fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%s\n", i.Number, i.State, dash(labels), dash(milestone), i.Title) |
| 177 | } | 177 | } |
| 178 | - tw.Flush() | 178 | + if err := tw.Flush(); err != nil { |
| 179 | + return err | ||
| 180 | + } | ||
| 179 | printPageFooter(cmd, page.Page) | 181 | printPageFooter(cmd, page.Page) |
| 180 | return nil | 182 | return nil |
| 181 | } | 183 | } |
| @@ -205,7 +207,7 @@ func runIssueView(cmd *cobra.Command, args []string) error { | |||
| 205 | if i.Milestone != nil { | 207 | if i.Milestone != nil { |
| 206 | state += " · " + i.Milestone.Title | 208 | state += " · " + i.Milestone.Title |
| 207 | } | 209 | } |
| 208 | - fmt.Fprintf(out, "Issue #%d %s [%s]\n", i.Number, i.Title, state) | 210 | + fmt.Fprintf(out, "Issue #%d %s [%s]\n", i.Number, clean(i.Title), state) |
| 209 | fmt.Fprintf(out, "by %s · %s\n", dash(i.Author), humanTime(i.CreatedAt)) | 211 | fmt.Fprintf(out, "by %s · %s\n", dash(i.Author), humanTime(i.CreatedAt)) |
| 210 | if len(i.Labels) > 0 { | 212 | if len(i.Labels) > 0 { |
| 211 | names := make([]string, len(i.Labels)) | 213 | names := make([]string, len(i.Labels)) |
| @@ -223,10 +225,10 @@ func runIssueView(cmd *cobra.Command, args []string) error { | |||
| 223 | } | 225 | } |
| 224 | fmt.Fprintln(out) | 226 | fmt.Fprintln(out) |
| 225 | if i.Body != "" { | 227 | if i.Body != "" { |
| 226 | - fmt.Fprintln(out, i.Body) | 228 | + fmt.Fprintln(out, clean(i.Body)) |
| 227 | } | 229 | } |
| 228 | for _, c := range i.Comments { | 230 | for _, c := range i.Comments { |
| 229 | - fmt.Fprintf(out, "\n--- %s (%s) ---\n%s\n", c.Author, humanTime(c.CreatedAt), c.Body) | 231 | + fmt.Fprintf(out, "\n--- %s (%s) ---\n%s\n", clean(c.Author), humanTime(c.CreatedAt), clean(c.Body)) |
| 230 | } | 232 | } |
| 231 | return nil | 233 | return nil |
| 232 | } | 234 | } |
| @@ -449,6 +451,5 @@ func runIssueLabelsList(cmd *cobra.Command, _ []string) error { | |||
| 449 | for _, l := range labels { | 451 | for _, l := range labels { |
| 450 | fmt.Fprintf(tw, "%s\t#%s\n", l.Name, l.Color) | 452 | fmt.Fprintf(tw, "%s\t#%s\n", l.Name, l.Color) |
| 451 | } | 453 | } |
| 452 | - tw.Flush() | 454 | + return tw.Flush() |
| 453 | - return nil | ||
| 454 | } | 455 | } |
modified
cmd/milestone.go +1 -2 | @@ -92,8 +92,7 @@ func runMilestoneList(cmd *cobra.Command, _ []string) error { | ||
| 92 | 92 | progress := fmt.Sprintf("%d/%d", m.ClosedCount, m.OpenCount+m.ClosedCount) |
| 93 | 93 | fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", m.ID, m.State, dash(deref(m.DueOn)), progress, m.Title) |
| 94 | 94 | } |
| 95 | - tw.Flush() | |
| 96 | - return nil | |
| 95 | + return tw.Flush() | |
| 97 | 96 | } |
| 98 | 97 | |
| 99 | 98 | func deref(s *string) string { |
| @@ -92,8 +92,7 @@ func runMilestoneList(cmd *cobra.Command, _ []string) error { | |||
| 92 | progress := fmt.Sprintf("%d/%d", m.ClosedCount, m.OpenCount+m.ClosedCount) | 92 | progress := fmt.Sprintf("%d/%d", m.ClosedCount, m.OpenCount+m.ClosedCount) |
| 93 | fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", m.ID, m.State, dash(deref(m.DueOn)), progress, m.Title) | 93 | fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", m.ID, m.State, dash(deref(m.DueOn)), progress, m.Title) |
| 94 | } | 94 | } |
| 95 | - tw.Flush() | 95 | + return tw.Flush() |
| 96 | - return nil | ||
| 97 | } | 96 | } |
| 98 | 97 | ||
| 99 | func deref(s *string) string { | 98 | func deref(s *string) string { |
modified
cmd/org.go +2 -4 | @@ -77,8 +77,7 @@ func runOrgMembers(cmd *cobra.Command, args []string) error { | ||
| 77 | 77 | for _, m := range members { |
| 78 | 78 | fmt.Fprintf(tw, "%s\t%s\t%s\n", m.Handle, m.Role, dash(m.DisplayName)) |
| 79 | 79 | } |
| 80 | - tw.Flush() | |
| 81 | - return nil | |
| 80 | + return tw.Flush() | |
| 82 | 81 | } |
| 83 | 82 | |
| 84 | 83 | func runOrgTeams(cmd *cobra.Command, args []string) error { |
| @@ -102,6 +101,5 @@ func runOrgTeams(cmd *cobra.Command, args []string) error { | ||
| 102 | 101 | for _, t := range teams { |
| 103 | 102 | fmt.Fprintf(tw, "%s\t%s\t%v\t%s\n", t.Slug, dash(t.Name), t.SubTeam, dash(t.Description)) |
| 104 | 103 | } |
| 105 | - tw.Flush() | |
| 106 | - return nil | |
| 104 | + return tw.Flush() | |
| 107 | 105 | } |
| @@ -77,8 +77,7 @@ func runOrgMembers(cmd *cobra.Command, args []string) error { | |||
| 77 | for _, m := range members { | 77 | for _, m := range members { |
| 78 | fmt.Fprintf(tw, "%s\t%s\t%s\n", m.Handle, m.Role, dash(m.DisplayName)) | 78 | fmt.Fprintf(tw, "%s\t%s\t%s\n", m.Handle, m.Role, dash(m.DisplayName)) |
| 79 | } | 79 | } |
| 80 | - tw.Flush() | 80 | + return tw.Flush() |
| 81 | - return nil | ||
| 82 | } | 81 | } |
| 83 | 82 | ||
| 84 | func runOrgTeams(cmd *cobra.Command, args []string) error { | 83 | func runOrgTeams(cmd *cobra.Command, args []string) error { |
| @@ -102,6 +101,5 @@ func runOrgTeams(cmd *cobra.Command, args []string) error { | |||
| 102 | for _, t := range teams { | 101 | for _, t := range teams { |
| 103 | fmt.Fprintf(tw, "%s\t%s\t%v\t%s\n", t.Slug, dash(t.Name), t.SubTeam, dash(t.Description)) | 102 | fmt.Fprintf(tw, "%s\t%s\t%v\t%s\n", t.Slug, dash(t.Name), t.SubTeam, dash(t.Description)) |
| 104 | } | 103 | } |
| 105 | - tw.Flush() | 104 | + return tw.Flush() |
| 106 | - return nil | ||
| 107 | } | 105 | } |
modified
cmd/pr.go +6 -4 | @@ -137,7 +137,9 @@ func runPRList(cmd *cobra.Command, _ []string) error { | ||
| 137 | 137 | for _, mr := range page.Items { |
| 138 | 138 | fmt.Fprintf(tw, "%d\t%s\t%s\t%s→%s\t%s\n", mr.Number, prStateLabel(mr), mr.Title, mr.HeadBranch, mr.BaseBranch, mr.Author) |
| 139 | 139 | } |
| 140 | - tw.Flush() | |
| 140 | + if err := tw.Flush(); err != nil { | |
| 141 | + return err | |
| 142 | + } | |
| 141 | 143 | printPageFooter(cmd, page.Page) |
| 142 | 144 | return nil |
| 143 | 145 | } |
| @@ -163,13 +165,13 @@ func runPRView(cmd *cobra.Command, args []string) error { | ||
| 163 | 165 | return printJSON(cmd.OutOrStdout(), mr) |
| 164 | 166 | } |
| 165 | 167 | out := cmd.OutOrStdout() |
| 166 | - fmt.Fprintf(out, "#%d %s [%s]\n", mr.Number, mr.Title, prStateLabel(mr.MergeRequest)) | |
| 168 | + fmt.Fprintf(out, "#%d %s [%s]\n", mr.Number, clean(mr.Title), prStateLabel(mr.MergeRequest)) | |
| 167 | 169 | fmt.Fprintf(out, "%s wants to merge %s → %s\n", mr.Author, mr.HeadBranch, mr.BaseBranch) |
| 168 | 170 | if mr.CrossRepo { |
| 169 | 171 | fmt.Fprintf(out, "cross-repo from %s/%s\n", mr.HeadOwner, mr.HeadRepo) |
| 170 | 172 | } |
| 171 | 173 | if mr.Body != "" { |
| 172 | - fmt.Fprintf(out, "\n%s\n", mr.Body) | |
| 174 | + fmt.Fprintf(out, "\n%s\n", clean(mr.Body)) | |
| 173 | 175 | } |
| 174 | 176 | if len(mr.Reviewers) > 0 { |
| 175 | 177 | fmt.Fprintf(out, "\nReviewers: %s\n", joinSubjects(mr.Reviewers)) |
| @@ -186,7 +188,7 @@ func runPRView(cmd *cobra.Command, args []string) error { | ||
| 186 | 188 | if len(mr.Comments) > 0 { |
| 187 | 189 | fmt.Fprintln(out, "\nComments:") |
| 188 | 190 | for _, c := range mr.Comments { |
| 189 | - fmt.Fprintf(out, " %s (%s):\n %s\n", c.Author, humanTime(c.CreatedAt), c.Body) | |
| 191 | + fmt.Fprintf(out, " %s (%s):\n %s\n", clean(c.Author), humanTime(c.CreatedAt), clean(c.Body)) | |
| 190 | 192 | } |
| 191 | 193 | } |
| 192 | 194 | return nil |
| @@ -137,7 +137,9 @@ func runPRList(cmd *cobra.Command, _ []string) error { | |||
| 137 | for _, mr := range page.Items { | 137 | for _, mr := range page.Items { |
| 138 | fmt.Fprintf(tw, "%d\t%s\t%s\t%s→%s\t%s\n", mr.Number, prStateLabel(mr), mr.Title, mr.HeadBranch, mr.BaseBranch, mr.Author) | 138 | fmt.Fprintf(tw, "%d\t%s\t%s\t%s→%s\t%s\n", mr.Number, prStateLabel(mr), mr.Title, mr.HeadBranch, mr.BaseBranch, mr.Author) |
| 139 | } | 139 | } |
| 140 | - tw.Flush() | 140 | + if err := tw.Flush(); err != nil { |
| 141 | + return err | ||
| 142 | + } | ||
| 141 | printPageFooter(cmd, page.Page) | 143 | printPageFooter(cmd, page.Page) |
| 142 | return nil | 144 | return nil |
| 143 | } | 145 | } |
| @@ -163,13 +165,13 @@ func runPRView(cmd *cobra.Command, args []string) error { | |||
| 163 | return printJSON(cmd.OutOrStdout(), mr) | 165 | return printJSON(cmd.OutOrStdout(), mr) |
| 164 | } | 166 | } |
| 165 | out := cmd.OutOrStdout() | 167 | out := cmd.OutOrStdout() |
| 166 | - fmt.Fprintf(out, "#%d %s [%s]\n", mr.Number, mr.Title, prStateLabel(mr.MergeRequest)) | 168 | + fmt.Fprintf(out, "#%d %s [%s]\n", mr.Number, clean(mr.Title), prStateLabel(mr.MergeRequest)) |
| 167 | fmt.Fprintf(out, "%s wants to merge %s → %s\n", mr.Author, mr.HeadBranch, mr.BaseBranch) | 169 | fmt.Fprintf(out, "%s wants to merge %s → %s\n", mr.Author, mr.HeadBranch, mr.BaseBranch) |
| 168 | if mr.CrossRepo { | 170 | if mr.CrossRepo { |
| 169 | fmt.Fprintf(out, "cross-repo from %s/%s\n", mr.HeadOwner, mr.HeadRepo) | 171 | fmt.Fprintf(out, "cross-repo from %s/%s\n", mr.HeadOwner, mr.HeadRepo) |
| 170 | } | 172 | } |
| 171 | if mr.Body != "" { | 173 | if mr.Body != "" { |
| 172 | - fmt.Fprintf(out, "\n%s\n", mr.Body) | 174 | + fmt.Fprintf(out, "\n%s\n", clean(mr.Body)) |
| 173 | } | 175 | } |
| 174 | if len(mr.Reviewers) > 0 { | 176 | if len(mr.Reviewers) > 0 { |
| 175 | fmt.Fprintf(out, "\nReviewers: %s\n", joinSubjects(mr.Reviewers)) | 177 | fmt.Fprintf(out, "\nReviewers: %s\n", joinSubjects(mr.Reviewers)) |
| @@ -186,7 +188,7 @@ func runPRView(cmd *cobra.Command, args []string) error { | |||
| 186 | if len(mr.Comments) > 0 { | 188 | if len(mr.Comments) > 0 { |
| 187 | fmt.Fprintln(out, "\nComments:") | 189 | fmt.Fprintln(out, "\nComments:") |
| 188 | for _, c := range mr.Comments { | 190 | for _, c := range mr.Comments { |
| 189 | - fmt.Fprintf(out, " %s (%s):\n %s\n", c.Author, humanTime(c.CreatedAt), c.Body) | 191 | + fmt.Fprintf(out, " %s (%s):\n %s\n", clean(c.Author), humanTime(c.CreatedAt), clean(c.Body)) |
| 190 | } | 192 | } |
| 191 | } | 193 | } |
| 192 | return nil | 194 | return nil |
modified
cmd/release_smoke_test.go +35 -6 | @@ -38,23 +38,52 @@ func TestCloneURLFor(t *testing.T) { | ||
| 38 | 38 | } |
| 39 | 39 | } |
| 40 | 40 | |
| 41 | -// The token reaches git as an extraHeader through GIT_CONFIG_*, appended after | |
| 42 | -// any entries the caller's environment already carries, so nothing of the | |
| 43 | -// user's own git config is clobbered and the token never touches argv. | |
| 41 | +// The token reaches git as an extraHeader through GIT_CONFIG_*, scoped to the | |
| 42 | +// clone origin so it is never sent to another host, appended after any entries | |
| 43 | +// the caller's environment already carries, and never on argv. | |
| 44 | 44 | func TestGitAuthEnv(t *testing.T) { |
| 45 | 45 | t.Setenv("GIT_CONFIG_COUNT", "") |
| 46 | - got := gitAuthEnv("rickub_pat_x") | |
| 47 | - want := []string{"GIT_CONFIG_COUNT=1", "GIT_CONFIG_KEY_0=http.extraHeader", "GIT_CONFIG_VALUE_0=Authorization: Bearer rickub_pat_x"} | |
| 46 | + got := gitAuthEnv("rickub_pat_x", "https://git.rickub.com/rickub/cli.git") | |
| 47 | + want := []string{ | |
| 48 | + "GIT_CONFIG_COUNT=1", | |
| 49 | + "GIT_CONFIG_KEY_0=http.https://git.rickub.com/.extraHeader", | |
| 50 | + "GIT_CONFIG_VALUE_0=Authorization: Bearer rickub_pat_x", | |
| 51 | + } | |
| 48 | 52 | if strings.Join(got, "\n") != strings.Join(want, "\n") { |
| 49 | 53 | t.Errorf("gitAuthEnv = %q, want %q", got, want) |
| 50 | 54 | } |
| 51 | 55 | t.Setenv("GIT_CONFIG_COUNT", "2") |
| 52 | - got = gitAuthEnv("rickub_pat_x") | |
| 56 | + got = gitAuthEnv("rickub_pat_x", "https://git.rickub.com/rickub/cli.git") | |
| 53 | 57 | if got[0] != "GIT_CONFIG_COUNT=3" || !strings.HasPrefix(got[1], "GIT_CONFIG_KEY_2=") { |
| 54 | 58 | t.Errorf("existing entries not preserved: %q", got) |
| 55 | 59 | } |
| 56 | 60 | } |
| 57 | 61 | |
| 62 | +// Server-provided text is printed for humans without terminal control | |
| 63 | +// sequences: an issue title cannot clear the screen or retitle the window. | |
| 64 | +func TestCleanStripsTerminalControls(t *testing.T) { | |
| 65 | + in := "ok\x1b[2J\x1b]0;pwned\x07 title\ttab\nline\x7f\u0085\u009b" | |
| 66 | + got := clean(in) | |
| 67 | + want := "ok[2J]0;pwned title\ttab\nline" | |
| 68 | + if got != want { | |
| 69 | + t.Errorf("clean(%q) = %q, want %q", in, got, want) | |
| 70 | + } | |
| 71 | + if clean("plain ünïcödé → text") != "plain ünïcödé → text" { | |
| 72 | + t.Error("clean must leave ordinary text alone") | |
| 73 | + } | |
| 74 | + var b strings.Builder | |
| 75 | + tw := newTabw(&b) | |
| 76 | + if _, err := tw.Write([]byte("a\x1b[31mred\tb\n")); err != nil { | |
| 77 | + t.Fatal(err) | |
| 78 | + } | |
| 79 | + if err := tw.Flush(); err != nil { | |
| 80 | + t.Fatal(err) | |
| 81 | + } | |
| 82 | + if strings.Contains(b.String(), "\x1b") { | |
| 83 | + t.Errorf("table output still carries ESC: %q", b.String()) | |
| 84 | + } | |
| 85 | +} | |
| 86 | + | |
| 58 | 87 | // pr create must say "draft": false out loud — the API's default is a draft, |
| 59 | 88 | // which `pr merge` then refuses (issue #2) — and --draft flips it. |
| 60 | 89 | func TestPRCreateSendsDraftExplicitly(t *testing.T) { |
| @@ -38,23 +38,52 @@ func TestCloneURLFor(t *testing.T) { | |||
| 38 | } | 38 | } |
| 39 | } | 39 | } |
| 40 | 40 | ||
| 41 | -// The token reaches git as an extraHeader through GIT_CONFIG_*, appended after | 41 | +// The token reaches git as an extraHeader through GIT_CONFIG_*, scoped to the |
| 42 | -// any entries the caller's environment already carries, so nothing of the | 42 | +// clone origin so it is never sent to another host, appended after any entries |
| 43 | -// user's own git config is clobbered and the token never touches argv. | 43 | +// the caller's environment already carries, and never on argv. |
| 44 | func TestGitAuthEnv(t *testing.T) { | 44 | func TestGitAuthEnv(t *testing.T) { |
| 45 | t.Setenv("GIT_CONFIG_COUNT", "") | 45 | t.Setenv("GIT_CONFIG_COUNT", "") |
| 46 | - got := gitAuthEnv("rickub_pat_x") | 46 | + got := gitAuthEnv("rickub_pat_x", "https://git.rickub.com/rickub/cli.git") |
| 47 | - want := []string{"GIT_CONFIG_COUNT=1", "GIT_CONFIG_KEY_0=http.extraHeader", "GIT_CONFIG_VALUE_0=Authorization: Bearer rickub_pat_x"} | 47 | + want := []string{ |
| 48 | + "GIT_CONFIG_COUNT=1", | ||
| 49 | + "GIT_CONFIG_KEY_0=http.https://git.rickub.com/.extraHeader", | ||
| 50 | + "GIT_CONFIG_VALUE_0=Authorization: Bearer rickub_pat_x", | ||
| 51 | + } | ||
| 48 | if strings.Join(got, "\n") != strings.Join(want, "\n") { | 52 | if strings.Join(got, "\n") != strings.Join(want, "\n") { |
| 49 | t.Errorf("gitAuthEnv = %q, want %q", got, want) | 53 | t.Errorf("gitAuthEnv = %q, want %q", got, want) |
| 50 | } | 54 | } |
| 51 | t.Setenv("GIT_CONFIG_COUNT", "2") | 55 | t.Setenv("GIT_CONFIG_COUNT", "2") |
| 52 | - got = gitAuthEnv("rickub_pat_x") | 56 | + got = gitAuthEnv("rickub_pat_x", "https://git.rickub.com/rickub/cli.git") |
| 53 | if got[0] != "GIT_CONFIG_COUNT=3" || !strings.HasPrefix(got[1], "GIT_CONFIG_KEY_2=") { | 57 | if got[0] != "GIT_CONFIG_COUNT=3" || !strings.HasPrefix(got[1], "GIT_CONFIG_KEY_2=") { |
| 54 | t.Errorf("existing entries not preserved: %q", got) | 58 | t.Errorf("existing entries not preserved: %q", got) |
| 55 | } | 59 | } |
| 56 | } | 60 | } |
| 57 | 61 | ||
| 62 | +// Server-provided text is printed for humans without terminal control | ||
| 63 | +// sequences: an issue title cannot clear the screen or retitle the window. | ||
| 64 | +func TestCleanStripsTerminalControls(t *testing.T) { | ||
| 65 | + in := "ok\x1b[2J\x1b]0;pwned\x07 title\ttab\nline\x7f\u0085\u009b" | ||
| 66 | + got := clean(in) | ||
| 67 | + want := "ok[2J]0;pwned title\ttab\nline" | ||
| 68 | + if got != want { | ||
| 69 | + t.Errorf("clean(%q) = %q, want %q", in, got, want) | ||
| 70 | + } | ||
| 71 | + if clean("plain ünïcödé → text") != "plain ünïcödé → text" { | ||
| 72 | + t.Error("clean must leave ordinary text alone") | ||
| 73 | + } | ||
| 74 | + var b strings.Builder | ||
| 75 | + tw := newTabw(&b) | ||
| 76 | + if _, err := tw.Write([]byte("a\x1b[31mred\tb\n")); err != nil { | ||
| 77 | + t.Fatal(err) | ||
| 78 | + } | ||
| 79 | + if err := tw.Flush(); err != nil { | ||
| 80 | + t.Fatal(err) | ||
| 81 | + } | ||
| 82 | + if strings.Contains(b.String(), "\x1b") { | ||
| 83 | + t.Errorf("table output still carries ESC: %q", b.String()) | ||
| 84 | + } | ||
| 85 | +} | ||
| 86 | + | ||
| 58 | // pr create must say "draft": false out loud — the API's default is a draft, | 87 | // pr create must say "draft": false out loud — the API's default is a draft, |
| 59 | // which `pr merge` then refuses (issue #2) — and --draft flips it. | 88 | // which `pr merge` then refuses (issue #2) — and --draft flips it. |
| 60 | func TestPRCreateSendsDraftExplicitly(t *testing.T) { | 89 | func TestPRCreateSendsDraftExplicitly(t *testing.T) { |
modified
cmd/repo.go +23 -8 | @@ -219,7 +219,9 @@ func runRepoList(cmd *cobra.Command, _ []string) error { | ||
| 219 | 219 | for _, r := range page.Items { |
| 220 | 220 | fmt.Fprintf(tw, "%s\t%s\t%s\n", r.FullName, r.Visibility, dash(r.Description)) |
| 221 | 221 | } |
| 222 | - tw.Flush() | |
| 222 | + if err := tw.Flush(); err != nil { | |
| 223 | + return err | |
| 224 | + } | |
| 223 | 225 | printPageFooter(cmd, page.Page) |
| 224 | 226 | return nil |
| 225 | 227 | } |
| @@ -324,6 +326,9 @@ func runRepoClone(cmd *cobra.Command, args []string) error { | ||
| 324 | 326 | gitArgs := []string{"clone", cloneURL} |
| 325 | 327 | gitArgs = append(gitArgs, args[1:]...) |
| 326 | 328 | fmt.Fprintf(cmd.OutOrStdout(), "Cloning %s…\n", cloneURL) |
| 329 | + // #nosec G204 -- the program is a constant ("git"); the URL is assembled here | |
| 330 | + // from a fixed scheme/host and the owner/repo the user named, and anything | |
| 331 | + // after "--" is the user's own git options, passed through by design. | |
| 327 | 332 | g := exec.Command("git", gitArgs...) |
| 328 | 333 | g.Stdout = cmd.OutOrStdout() |
| 329 | 334 | g.Stderr = cmd.ErrOrStderr() |
| @@ -335,7 +340,7 @@ func runRepoClone(cmd *cobra.Command, args []string) error { | ||
| 335 | 340 | // ps) and never written into the clone's .git/config. |
| 336 | 341 | if token := config.ResolveToken(flagToken, cfg, host); token != "" { |
| 337 | 342 | config.WarnIfInsecure(cmd.ErrOrStderr(), cloneURL) |
| 338 | - g.Env = append(g.Env, gitAuthEnv(token)...) | |
| 343 | + g.Env = append(g.Env, gitAuthEnv(token, cloneURL)...) | |
| 339 | 344 | } |
| 340 | 345 | } |
| 341 | 346 | return g.Run() |
| @@ -366,7 +371,14 @@ func cloneURLFor(host, owner, repo, protocol string) (string, error) { | ||
| 366 | 371 | |
| 367 | 372 | // gitAuthEnv returns the GIT_CONFIG_* variables that make git send the token as |
| 368 | 373 | // a bearer header, appended after any GIT_CONFIG_COUNT entries already present. |
| 369 | -func gitAuthEnv(token string) []string { | |
| 374 | +// The header is scoped to the clone URL's origin with git's per-URL config | |
| 375 | +// (http.<origin>/.extraHeader), so it is never sent to another host: not to a | |
| 376 | +// redirect target, and not to a submodule hosted elsewhere. | |
| 377 | +func gitAuthEnv(token, cloneURL string) []string { | |
| 378 | + origin := cloneURL | |
| 379 | + if u, err := url.Parse(cloneURL); err == nil && u.Scheme != "" && u.Host != "" { | |
| 380 | + origin = u.Scheme + "://" + u.Host + "/" | |
| 381 | + } | |
| 370 | 382 | n := 0 |
| 371 | 383 | if v := os.Getenv("GIT_CONFIG_COUNT"); v != "" { |
| 372 | 384 | if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 { |
| @@ -375,7 +387,7 @@ func gitAuthEnv(token string) []string { | ||
| 375 | 387 | } |
| 376 | 388 | return []string{ |
| 377 | 389 | fmt.Sprintf("GIT_CONFIG_COUNT=%d", n+1), |
| 378 | - fmt.Sprintf("GIT_CONFIG_KEY_%d=http.extraHeader", n), | |
| 390 | + fmt.Sprintf("GIT_CONFIG_KEY_%d=http.%s.extraHeader", n, origin), | |
| 379 | 391 | fmt.Sprintf("GIT_CONFIG_VALUE_%d=Authorization: Bearer %s", n, token), |
| 380 | 392 | } |
| 381 | 393 | } |
| @@ -426,8 +438,7 @@ func runRepoFiles(cmd *cobra.Command, args []string) error { | ||
| 426 | 438 | } |
| 427 | 439 | fmt.Fprintf(tw, "%s\t%s\t%s\n", kind, e.Name, size) |
| 428 | 440 | } |
| 429 | - tw.Flush() | |
| 430 | - return nil | |
| 441 | + return tw.Flush() | |
| 431 | 442 | } |
| 432 | 443 | |
| 433 | 444 | func runRepoCat(cmd *cobra.Command, args []string) error { |
| @@ -510,7 +521,9 @@ func runRepoCommits(cmd *cobra.Command, args []string) error { | ||
| 510 | 521 | for _, c := range page.Items { |
| 511 | 522 | fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", c.Short, dash(c.Author), dash(c.Date), c.Subject) |
| 512 | 523 | } |
| 513 | - tw.Flush() | |
| 524 | + if err := tw.Flush(); err != nil { | |
| 525 | + return err | |
| 526 | + } | |
| 514 | 527 | printPageFooter(cmd, page.Page) |
| 515 | 528 | return nil |
| 516 | 529 | } |
| @@ -540,7 +553,9 @@ func runRepoCompare(cmd *cobra.Command, args []string) error { | ||
| 540 | 553 | for _, c := range cmp.Commits { |
| 541 | 554 | fmt.Fprintf(tw, "%s\t%s\t%s\n", c.Short, dash(c.Author), c.Subject) |
| 542 | 555 | } |
| 543 | - tw.Flush() | |
| 556 | + if err := tw.Flush(); err != nil { | |
| 557 | + return err | |
| 558 | + } | |
| 544 | 559 | } |
| 545 | 560 | if len(cmp.Files) > 0 { |
| 546 | 561 | fmt.Fprintf(out, "\n%d file(s) changed:\n", len(cmp.Files)) |
| @@ -219,7 +219,9 @@ func runRepoList(cmd *cobra.Command, _ []string) error { | |||
| 219 | for _, r := range page.Items { | 219 | for _, r := range page.Items { |
| 220 | fmt.Fprintf(tw, "%s\t%s\t%s\n", r.FullName, r.Visibility, dash(r.Description)) | 220 | fmt.Fprintf(tw, "%s\t%s\t%s\n", r.FullName, r.Visibility, dash(r.Description)) |
| 221 | } | 221 | } |
| 222 | - tw.Flush() | 222 | + if err := tw.Flush(); err != nil { |
| 223 | + return err | ||
| 224 | + } | ||
| 223 | printPageFooter(cmd, page.Page) | 225 | printPageFooter(cmd, page.Page) |
| 224 | return nil | 226 | return nil |
| 225 | } | 227 | } |
| @@ -324,6 +326,9 @@ func runRepoClone(cmd *cobra.Command, args []string) error { | |||
| 324 | gitArgs := []string{"clone", cloneURL} | 326 | gitArgs := []string{"clone", cloneURL} |
| 325 | gitArgs = append(gitArgs, args[1:]...) | 327 | gitArgs = append(gitArgs, args[1:]...) |
| 326 | fmt.Fprintf(cmd.OutOrStdout(), "Cloning %s…\n", cloneURL) | 328 | fmt.Fprintf(cmd.OutOrStdout(), "Cloning %s…\n", cloneURL) |
| 329 | + // #nosec G204 -- the program is a constant ("git"); the URL is assembled here | ||
| 330 | + // from a fixed scheme/host and the owner/repo the user named, and anything | ||
| 331 | + // after "--" is the user's own git options, passed through by design. | ||
| 327 | g := exec.Command("git", gitArgs...) | 332 | g := exec.Command("git", gitArgs...) |
| 328 | g.Stdout = cmd.OutOrStdout() | 333 | g.Stdout = cmd.OutOrStdout() |
| 329 | g.Stderr = cmd.ErrOrStderr() | 334 | g.Stderr = cmd.ErrOrStderr() |
| @@ -335,7 +340,7 @@ func runRepoClone(cmd *cobra.Command, args []string) error { | |||
| 335 | // ps) and never written into the clone's .git/config. | 340 | // ps) and never written into the clone's .git/config. |
| 336 | if token := config.ResolveToken(flagToken, cfg, host); token != "" { | 341 | if token := config.ResolveToken(flagToken, cfg, host); token != "" { |
| 337 | config.WarnIfInsecure(cmd.ErrOrStderr(), cloneURL) | 342 | config.WarnIfInsecure(cmd.ErrOrStderr(), cloneURL) |
| 338 | - g.Env = append(g.Env, gitAuthEnv(token)...) | 343 | + g.Env = append(g.Env, gitAuthEnv(token, cloneURL)...) |
| 339 | } | 344 | } |
| 340 | } | 345 | } |
| 341 | return g.Run() | 346 | return g.Run() |
| @@ -366,7 +371,14 @@ func cloneURLFor(host, owner, repo, protocol string) (string, error) { | |||
| 366 | 371 | ||
| 367 | // gitAuthEnv returns the GIT_CONFIG_* variables that make git send the token as | 372 | // gitAuthEnv returns the GIT_CONFIG_* variables that make git send the token as |
| 368 | // a bearer header, appended after any GIT_CONFIG_COUNT entries already present. | 373 | // a bearer header, appended after any GIT_CONFIG_COUNT entries already present. |
| 369 | -func gitAuthEnv(token string) []string { | 374 | +// The header is scoped to the clone URL's origin with git's per-URL config |
| 375 | +// (http.<origin>/.extraHeader), so it is never sent to another host: not to a | ||
| 376 | +// redirect target, and not to a submodule hosted elsewhere. | ||
| 377 | +func gitAuthEnv(token, cloneURL string) []string { | ||
| 378 | + origin := cloneURL | ||
| 379 | + if u, err := url.Parse(cloneURL); err == nil && u.Scheme != "" && u.Host != "" { | ||
| 380 | + origin = u.Scheme + "://" + u.Host + "/" | ||
| 381 | + } | ||
| 370 | n := 0 | 382 | n := 0 |
| 371 | if v := os.Getenv("GIT_CONFIG_COUNT"); v != "" { | 383 | if v := os.Getenv("GIT_CONFIG_COUNT"); v != "" { |
| 372 | if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 { | 384 | if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 { |
| @@ -375,7 +387,7 @@ func gitAuthEnv(token string) []string { | |||
| 375 | } | 387 | } |
| 376 | return []string{ | 388 | return []string{ |
| 377 | fmt.Sprintf("GIT_CONFIG_COUNT=%d", n+1), | 389 | fmt.Sprintf("GIT_CONFIG_COUNT=%d", n+1), |
| 378 | - fmt.Sprintf("GIT_CONFIG_KEY_%d=http.extraHeader", n), | 390 | + fmt.Sprintf("GIT_CONFIG_KEY_%d=http.%s.extraHeader", n, origin), |
| 379 | fmt.Sprintf("GIT_CONFIG_VALUE_%d=Authorization: Bearer %s", n, token), | 391 | fmt.Sprintf("GIT_CONFIG_VALUE_%d=Authorization: Bearer %s", n, token), |
| 380 | } | 392 | } |
| 381 | } | 393 | } |
| @@ -426,8 +438,7 @@ func runRepoFiles(cmd *cobra.Command, args []string) error { | |||
| 426 | } | 438 | } |
| 427 | fmt.Fprintf(tw, "%s\t%s\t%s\n", kind, e.Name, size) | 439 | fmt.Fprintf(tw, "%s\t%s\t%s\n", kind, e.Name, size) |
| 428 | } | 440 | } |
| 429 | - tw.Flush() | 441 | + return tw.Flush() |
| 430 | - return nil | ||
| 431 | } | 442 | } |
| 432 | 443 | ||
| 433 | func runRepoCat(cmd *cobra.Command, args []string) error { | 444 | func runRepoCat(cmd *cobra.Command, args []string) error { |
| @@ -510,7 +521,9 @@ func runRepoCommits(cmd *cobra.Command, args []string) error { | |||
| 510 | for _, c := range page.Items { | 521 | for _, c := range page.Items { |
| 511 | fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", c.Short, dash(c.Author), dash(c.Date), c.Subject) | 522 | fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", c.Short, dash(c.Author), dash(c.Date), c.Subject) |
| 512 | } | 523 | } |
| 513 | - tw.Flush() | 524 | + if err := tw.Flush(); err != nil { |
| 525 | + return err | ||
| 526 | + } | ||
| 514 | printPageFooter(cmd, page.Page) | 527 | printPageFooter(cmd, page.Page) |
| 515 | return nil | 528 | return nil |
| 516 | } | 529 | } |
| @@ -540,7 +553,9 @@ func runRepoCompare(cmd *cobra.Command, args []string) error { | |||
| 540 | for _, c := range cmp.Commits { | 553 | for _, c := range cmp.Commits { |
| 541 | fmt.Fprintf(tw, "%s\t%s\t%s\n", c.Short, dash(c.Author), c.Subject) | 554 | fmt.Fprintf(tw, "%s\t%s\t%s\n", c.Short, dash(c.Author), c.Subject) |
| 542 | } | 555 | } |
| 543 | - tw.Flush() | 556 | + if err := tw.Flush(); err != nil { |
| 557 | + return err | ||
| 558 | + } | ||
| 544 | } | 559 | } |
| 545 | if len(cmp.Files) > 0 { | 560 | if len(cmp.Files) > 0 { |
| 546 | fmt.Fprintf(out, "\n%d file(s) changed:\n", len(cmp.Files)) | 561 | fmt.Fprintf(out, "\n%d file(s) changed:\n", len(cmp.Files)) |
modified
cmd/root.go +49 -6 | @@ -2,11 +2,11 @@ | ||
| 2 | 2 | package cmd |
| 3 | 3 | |
| 4 | 4 | import ( |
| 5 | - "context" | |
| 6 | 5 | "encoding/json" |
| 7 | 6 | "fmt" |
| 8 | 7 | "io" |
| 9 | 8 | "os" |
| 9 | + "strings" | |
| 10 | 10 | "text/tabwriter" |
| 11 | 11 | "time" |
| 12 | 12 | |
| @@ -79,9 +79,6 @@ func newClient() (*api.Client, error) { | ||
| 79 | 79 | return api.New(host, token), nil |
| 80 | 80 | } |
| 81 | 81 | |
| 82 | -// ctx returns a background context (a place to add cancellation later). | |
| 83 | -func ctx() context.Context { return context.Background() } | |
| 84 | - | |
| 85 | 82 | // ---- output helpers ---- |
| 86 | 83 | |
| 87 | 84 | // printJSON marshals v as indented JSON to the given writer. |
| @@ -93,9 +90,55 @@ func printJSON(w io.Writer, v any) error { | ||
| 93 | 90 | } |
| 94 | 91 | |
| 95 | 92 | // newTabw returns a tabwriter over the given writer configured for the CLI's |
| 96 | -// two-space-padded column style. | |
| 93 | +// two-space-padded column style. Table cells are server-provided text (titles, | |
| 94 | +// names, descriptions), so the output passes through cleanWriter: a terminal | |
| 95 | +// escape sequence smuggled into an issue title cannot repaint the screen, | |
| 96 | +// change the window title, or hide a row. | |
| 97 | 97 | func newTabw(w io.Writer) *tabwriter.Writer { |
| 98 | - return tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) | |
| 98 | + return tabwriter.NewWriter(cleanWriter{w}, 0, 4, 2, ' ', 0) | |
| 99 | +} | |
| 100 | + | |
| 101 | +// clean strips terminal control characters from text that came from the | |
| 102 | +// server before it is printed for a human: every C0 control except tab, | |
| 103 | +// newline and carriage return, DEL, and the C1 range (U+0080–U+009F, which | |
| 104 | +// some terminals honour as 8-bit escape introducers). JSON output is never | |
| 105 | +// touched — it goes through printJSON untransformed. | |
| 106 | +func clean(s string) string { | |
| 107 | + if !needsClean(s) { | |
| 108 | + return s | |
| 109 | + } | |
| 110 | + var b strings.Builder | |
| 111 | + b.Grow(len(s)) | |
| 112 | + for _, r := range s { | |
| 113 | + switch { | |
| 114 | + case r == '\t' || r == '\n' || r == '\r': | |
| 115 | + b.WriteRune(r) | |
| 116 | + case r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f): | |
| 117 | + // dropped | |
| 118 | + default: | |
| 119 | + b.WriteRune(r) | |
| 120 | + } | |
| 121 | + } | |
| 122 | + return b.String() | |
| 123 | +} | |
| 124 | + | |
| 125 | +func needsClean(s string) bool { | |
| 126 | + for _, r := range s { | |
| 127 | + if (r < 0x20 && r != '\t' && r != '\n' && r != '\r') || r == 0x7f || (r >= 0x80 && r <= 0x9f) { | |
| 128 | + return true | |
| 129 | + } | |
| 130 | + } | |
| 131 | + return false | |
| 132 | +} | |
| 133 | + | |
| 134 | +// cleanWriter applies clean to everything written through it. | |
| 135 | +type cleanWriter struct{ w io.Writer } | |
| 136 | + | |
| 137 | +func (c cleanWriter) Write(p []byte) (int, error) { | |
| 138 | + if _, err := c.w.Write([]byte(clean(string(p)))); err != nil { | |
| 139 | + return 0, err | |
| 140 | + } | |
| 141 | + return len(p), nil | |
| 99 | 142 | } |
| 100 | 143 | |
| 101 | 144 | // printPageFooter prints a pagination hint when more pages are available. |
| @@ -2,11 +2,11 @@ | |||
| 2 | package cmd | 2 | package cmd |
| 3 | 3 | ||
| 4 | import ( | 4 | import ( |
| 5 | - "context" | ||
| 6 | "encoding/json" | 5 | "encoding/json" |
| 7 | "fmt" | 6 | "fmt" |
| 8 | "io" | 7 | "io" |
| 9 | "os" | 8 | "os" |
| 9 | + "strings" | ||
| 10 | "text/tabwriter" | 10 | "text/tabwriter" |
| 11 | "time" | 11 | "time" |
| 12 | 12 | ||
| @@ -79,9 +79,6 @@ func newClient() (*api.Client, error) { | |||
| 79 | return api.New(host, token), nil | 79 | return api.New(host, token), nil |
| 80 | } | 80 | } |
| 81 | 81 | ||
| 82 | -// ctx returns a background context (a place to add cancellation later). | ||
| 83 | -func ctx() context.Context { return context.Background() } | ||
| 84 | - | ||
| 85 | // ---- output helpers ---- | 82 | // ---- output helpers ---- |
| 86 | 83 | ||
| 87 | // printJSON marshals v as indented JSON to the given writer. | 84 | // printJSON marshals v as indented JSON to the given writer. |
| @@ -93,9 +90,55 @@ func printJSON(w io.Writer, v any) error { | |||
| 93 | } | 90 | } |
| 94 | 91 | ||
| 95 | // newTabw returns a tabwriter over the given writer configured for the CLI's | 92 | // newTabw returns a tabwriter over the given writer configured for the CLI's |
| 96 | -// two-space-padded column style. | 93 | +// two-space-padded column style. Table cells are server-provided text (titles, |
| 94 | +// names, descriptions), so the output passes through cleanWriter: a terminal | ||
| 95 | +// escape sequence smuggled into an issue title cannot repaint the screen, | ||
| 96 | +// change the window title, or hide a row. | ||
| 97 | func newTabw(w io.Writer) *tabwriter.Writer { | 97 | func newTabw(w io.Writer) *tabwriter.Writer { |
| 98 | - return tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) | 98 | + return tabwriter.NewWriter(cleanWriter{w}, 0, 4, 2, ' ', 0) |
| 99 | +} | ||
| 100 | + | ||
| 101 | +// clean strips terminal control characters from text that came from the | ||
| 102 | +// server before it is printed for a human: every C0 control except tab, | ||
| 103 | +// newline and carriage return, DEL, and the C1 range (U+0080–U+009F, which | ||
| 104 | +// some terminals honour as 8-bit escape introducers). JSON output is never | ||
| 105 | +// touched — it goes through printJSON untransformed. | ||
| 106 | +func clean(s string) string { | ||
| 107 | + if !needsClean(s) { | ||
| 108 | + return s | ||
| 109 | + } | ||
| 110 | + var b strings.Builder | ||
| 111 | + b.Grow(len(s)) | ||
| 112 | + for _, r := range s { | ||
| 113 | + switch { | ||
| 114 | + case r == '\t' || r == '\n' || r == '\r': | ||
| 115 | + b.WriteRune(r) | ||
| 116 | + case r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f): | ||
| 117 | + // dropped | ||
| 118 | + default: | ||
| 119 | + b.WriteRune(r) | ||
| 120 | + } | ||
| 121 | + } | ||
| 122 | + return b.String() | ||
| 123 | +} | ||
| 124 | + | ||
| 125 | +func needsClean(s string) bool { | ||
| 126 | + for _, r := range s { | ||
| 127 | + if (r < 0x20 && r != '\t' && r != '\n' && r != '\r') || r == 0x7f || (r >= 0x80 && r <= 0x9f) { | ||
| 128 | + return true | ||
| 129 | + } | ||
| 130 | + } | ||
| 131 | + return false | ||
| 132 | +} | ||
| 133 | + | ||
| 134 | +// cleanWriter applies clean to everything written through it. | ||
| 135 | +type cleanWriter struct{ w io.Writer } | ||
| 136 | + | ||
| 137 | +func (c cleanWriter) Write(p []byte) (int, error) { | ||
| 138 | + if _, err := c.w.Write([]byte(clean(string(p)))); err != nil { | ||
| 139 | + return 0, err | ||
| 140 | + } | ||
| 141 | + return len(p), nil | ||
| 99 | } | 142 | } |
| 100 | 143 | ||
| 101 | // printPageFooter prints a pagination hint when more pages are available. | 144 | // printPageFooter prints a pagination hint when more pages are available. |
modified
cmd/run.go +4 -2 | @@ -117,7 +117,9 @@ func runRunList(cmd *cobra.Command, _ []string) error { | ||
| 117 | 117 | for _, r := range page.Items { |
| 118 | 118 | fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%s\t%s\n", r.Number, dash(r.Workflow), dash(r.Event), dash(r.Branch), r.Status, short(r.HeadSHA)) |
| 119 | 119 | } |
| 120 | - tw.Flush() | |
| 120 | + if err := tw.Flush(); err != nil { | |
| 121 | + return err | |
| 122 | + } | |
| 121 | 123 | printPageFooter(cmd, page.Page) |
| 122 | 124 | return nil |
| 123 | 125 | } |
| @@ -150,7 +152,7 @@ func runRunView(cmd *cobra.Command, args []string) error { | ||
| 150 | 152 | return printJSON(cmd.OutOrStdout(), r) |
| 151 | 153 | } |
| 152 | 154 | out := cmd.OutOrStdout() |
| 153 | - fmt.Fprintf(out, "Run #%d %s [%s]\n", r.Number, dash(r.Workflow), r.Status) | |
| 155 | + fmt.Fprintf(out, "Run #%d %s [%s]\n", r.Number, dash(clean(r.Workflow)), r.Status) | |
| 154 | 156 | fmt.Fprintf(out, "event: %s branch: %s commit: %s\n", dash(r.Event), dash(r.Branch), short(r.HeadSHA)) |
| 155 | 157 | if r.SecretsWithheld { |
| 156 | 158 | fmt.Fprintln(out, "note: secrets were withheld from this run") |
| @@ -117,7 +117,9 @@ func runRunList(cmd *cobra.Command, _ []string) error { | |||
| 117 | for _, r := range page.Items { | 117 | for _, r := range page.Items { |
| 118 | fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%s\t%s\n", r.Number, dash(r.Workflow), dash(r.Event), dash(r.Branch), r.Status, short(r.HeadSHA)) | 118 | fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%s\t%s\n", r.Number, dash(r.Workflow), dash(r.Event), dash(r.Branch), r.Status, short(r.HeadSHA)) |
| 119 | } | 119 | } |
| 120 | - tw.Flush() | 120 | + if err := tw.Flush(); err != nil { |
| 121 | + return err | ||
| 122 | + } | ||
| 121 | printPageFooter(cmd, page.Page) | 123 | printPageFooter(cmd, page.Page) |
| 122 | return nil | 124 | return nil |
| 123 | } | 125 | } |
| @@ -150,7 +152,7 @@ func runRunView(cmd *cobra.Command, args []string) error { | |||
| 150 | return printJSON(cmd.OutOrStdout(), r) | 152 | return printJSON(cmd.OutOrStdout(), r) |
| 151 | } | 153 | } |
| 152 | out := cmd.OutOrStdout() | 154 | out := cmd.OutOrStdout() |
| 153 | - fmt.Fprintf(out, "Run #%d %s [%s]\n", r.Number, dash(r.Workflow), r.Status) | 155 | + fmt.Fprintf(out, "Run #%d %s [%s]\n", r.Number, dash(clean(r.Workflow)), r.Status) |
| 154 | fmt.Fprintf(out, "event: %s branch: %s commit: %s\n", dash(r.Event), dash(r.Branch), short(r.HeadSHA)) | 156 | fmt.Fprintf(out, "event: %s branch: %s commit: %s\n", dash(r.Event), dash(r.Branch), short(r.HeadSHA)) |
| 155 | if r.SecretsWithheld { | 157 | if r.SecretsWithheld { |
| 156 | fmt.Fprintln(out, "note: secrets were withheld from this run") | 158 | fmt.Fprintln(out, "note: secrets were withheld from this run") |
modified
cmd/search.go +3 -1 | @@ -47,7 +47,9 @@ func runSearchRepos(cmd *cobra.Command, args []string) error { | ||
| 47 | 47 | for _, r := range page.Items { |
| 48 | 48 | fmt.Fprintf(tw, "%s\t%s\t%s\n", r.FullName, r.Visibility, dash(r.Description)) |
| 49 | 49 | } |
| 50 | - tw.Flush() | |
| 50 | + if err := tw.Flush(); err != nil { | |
| 51 | + return err | |
| 52 | + } | |
| 51 | 53 | printPageFooter(cmd, page.Page) |
| 52 | 54 | return nil |
| 53 | 55 | } |
| @@ -47,7 +47,9 @@ func runSearchRepos(cmd *cobra.Command, args []string) error { | |||
| 47 | for _, r := range page.Items { | 47 | for _, r := range page.Items { |
| 48 | fmt.Fprintf(tw, "%s\t%s\t%s\n", r.FullName, r.Visibility, dash(r.Description)) | 48 | fmt.Fprintf(tw, "%s\t%s\t%s\n", r.FullName, r.Visibility, dash(r.Description)) |
| 49 | } | 49 | } |
| 50 | - tw.Flush() | 50 | + if err := tw.Flush(); err != nil { |
| 51 | + return err | ||
| 52 | + } | ||
| 51 | printPageFooter(cmd, page.Page) | 53 | printPageFooter(cmd, page.Page) |
| 52 | return nil | 54 | return nil |
| 53 | } | 55 | } |
modified
go.mod +2 -0 | @@ -2,6 +2,8 @@ module rickub.com/rickub/cli | ||
| 2 | 2 | |
| 3 | 3 | go 1.26 |
| 4 | 4 | |
| 5 | +toolchain go1.26.8 | |
| 6 | + | |
| 5 | 7 | require ( |
| 6 | 8 | github.com/spf13/cobra v1.10.2 |
| 7 | 9 | gopkg.in/yaml.v3 v3.0.1 |
| @@ -2,6 +2,8 @@ module rickub.com/rickub/cli | |||
| 2 | 2 | ||
| 3 | go 1.26 | 3 | go 1.26 |
| 4 | 4 | ||
| 5 | +toolchain go1.26.8 | ||
| 6 | + | ||
| 5 | require ( | 7 | require ( |
| 6 | github.com/spf13/cobra v1.10.2 | 8 | github.com/spf13/cobra v1.10.2 |
| 7 | gopkg.in/yaml.v3 v3.0.1 | 9 | gopkg.in/yaml.v3 v3.0.1 |
modified
internal/api/client.go +37 -5 | @@ -26,16 +26,44 @@ type Client struct { | ||
| 26 | 26 | UserAgent string |
| 27 | 27 | } |
| 28 | 28 | |
| 29 | +// MaxResponseBytes caps how much of a response body the client will read. A | |
| 30 | +// misbehaving or hostile host must not be able to make the CLI buffer an | |
| 31 | +// unbounded body in memory; the largest legitimate responses are CI logs. | |
| 32 | +var MaxResponseBytes int64 = 128 << 20 | |
| 33 | + | |
| 29 | 34 | // New builds a Client with a sane default HTTP client. |
| 30 | 35 | func New(host, token string) *Client { |
| 31 | 36 | return &Client{ |
| 32 | - Host: strings.TrimRight(host, "/"), | |
| 33 | - Token: token, | |
| 34 | - HTTPClient: &http.Client{Timeout: 30 * time.Second}, | |
| 35 | - UserAgent: "rickub-cli", | |
| 37 | + Host: strings.TrimRight(host, "/"), | |
| 38 | + Token: token, | |
| 39 | + HTTPClient: &http.Client{ | |
| 40 | + Timeout: 30 * time.Second, | |
| 41 | + CheckRedirect: refuseUnsafeRedirect, | |
| 42 | + }, | |
| 43 | + UserAgent: "rickub-cli", | |
| 36 | 44 | } |
| 37 | 45 | } |
| 38 | 46 | |
| 47 | +// refuseUnsafeRedirect is the redirect policy: a redirect may stay on the same | |
| 48 | +// host and may never downgrade https to http. The bearer token rides on every | |
| 49 | +// request, so following a redirect elsewhere would hand it to another party | |
| 50 | +// (Go already drops Authorization across domains, but a same-domain http | |
| 51 | +// downgrade or a subdomain hop would still carry it), and a CLI has no | |
| 52 | +// business being bounced to a different origin anyway. | |
| 53 | +func refuseUnsafeRedirect(req *http.Request, via []*http.Request) error { | |
| 54 | + if len(via) >= 10 { | |
| 55 | + return fmt.Errorf("stopped after 10 redirects") | |
| 56 | + } | |
| 57 | + first := via[0].URL | |
| 58 | + if !strings.EqualFold(req.URL.Host, first.Host) { | |
| 59 | + return fmt.Errorf("refusing redirect from %s to another host (%s)", first.Host, req.URL.Host) | |
| 60 | + } | |
| 61 | + if strings.EqualFold(first.Scheme, "https") && !strings.EqualFold(req.URL.Scheme, "https") { | |
| 62 | + return fmt.Errorf("refusing redirect from https to %s", req.URL.Scheme) | |
| 63 | + } | |
| 64 | + return nil | |
| 65 | +} | |
| 66 | + | |
| 39 | 67 | // APIError is the parsed `{error:{code,message}}` envelope plus HTTP status. |
| 40 | 68 | type APIError struct { |
| 41 | 69 | Status int |
| @@ -113,10 +141,14 @@ func (c *Client) raw(ctx context.Context, r Request) (int, []byte, string, error | ||
| 113 | 141 | } |
| 114 | 142 | defer resp.Body.Close() |
| 115 | 143 | |
| 116 | - data, err := io.ReadAll(resp.Body) | |
| 144 | + data, err := io.ReadAll(io.LimitReader(resp.Body, MaxResponseBytes+1)) | |
| 117 | 145 | if err != nil { |
| 118 | 146 | return resp.StatusCode, nil, resp.Header.Get("Content-Type"), err |
| 119 | 147 | } |
| 148 | + if int64(len(data)) > MaxResponseBytes { | |
| 149 | + return resp.StatusCode, nil, resp.Header.Get("Content-Type"), | |
| 150 | + fmt.Errorf("response from %s exceeds %d bytes; refusing to buffer it", c.Host, MaxResponseBytes) | |
| 151 | + } | |
| 120 | 152 | |
| 121 | 153 | if resp.StatusCode >= 200 && resp.StatusCode < 300 { |
| 122 | 154 | return resp.StatusCode, data, resp.Header.Get("Content-Type"), nil |
| @@ -26,16 +26,44 @@ type Client struct { | |||
| 26 | UserAgent string | 26 | UserAgent string |
| 27 | } | 27 | } |
| 28 | 28 | ||
| 29 | +// MaxResponseBytes caps how much of a response body the client will read. A | ||
| 30 | +// misbehaving or hostile host must not be able to make the CLI buffer an | ||
| 31 | +// unbounded body in memory; the largest legitimate responses are CI logs. | ||
| 32 | +var MaxResponseBytes int64 = 128 << 20 | ||
| 33 | + | ||
| 29 | // New builds a Client with a sane default HTTP client. | 34 | // New builds a Client with a sane default HTTP client. |
| 30 | func New(host, token string) *Client { | 35 | func New(host, token string) *Client { |
| 31 | return &Client{ | 36 | return &Client{ |
| 32 | - Host: strings.TrimRight(host, "/"), | 37 | + Host: strings.TrimRight(host, "/"), |
| 33 | - Token: token, | 38 | + Token: token, |
| 34 | - HTTPClient: &http.Client{Timeout: 30 * time.Second}, | 39 | + HTTPClient: &http.Client{ |
| 35 | - UserAgent: "rickub-cli", | 40 | + Timeout: 30 * time.Second, |
| 41 | + CheckRedirect: refuseUnsafeRedirect, | ||
| 42 | + }, | ||
| 43 | + UserAgent: "rickub-cli", | ||
| 36 | } | 44 | } |
| 37 | } | 45 | } |
| 38 | 46 | ||
| 47 | +// refuseUnsafeRedirect is the redirect policy: a redirect may stay on the same | ||
| 48 | +// host and may never downgrade https to http. The bearer token rides on every | ||
| 49 | +// request, so following a redirect elsewhere would hand it to another party | ||
| 50 | +// (Go already drops Authorization across domains, but a same-domain http | ||
| 51 | +// downgrade or a subdomain hop would still carry it), and a CLI has no | ||
| 52 | +// business being bounced to a different origin anyway. | ||
| 53 | +func refuseUnsafeRedirect(req *http.Request, via []*http.Request) error { | ||
| 54 | + if len(via) >= 10 { | ||
| 55 | + return fmt.Errorf("stopped after 10 redirects") | ||
| 56 | + } | ||
| 57 | + first := via[0].URL | ||
| 58 | + if !strings.EqualFold(req.URL.Host, first.Host) { | ||
| 59 | + return fmt.Errorf("refusing redirect from %s to another host (%s)", first.Host, req.URL.Host) | ||
| 60 | + } | ||
| 61 | + if strings.EqualFold(first.Scheme, "https") && !strings.EqualFold(req.URL.Scheme, "https") { | ||
| 62 | + return fmt.Errorf("refusing redirect from https to %s", req.URL.Scheme) | ||
| 63 | + } | ||
| 64 | + return nil | ||
| 65 | +} | ||
| 66 | + | ||
| 39 | // APIError is the parsed `{error:{code,message}}` envelope plus HTTP status. | 67 | // APIError is the parsed `{error:{code,message}}` envelope plus HTTP status. |
| 40 | type APIError struct { | 68 | type APIError struct { |
| 41 | Status int | 69 | Status int |
| @@ -113,10 +141,14 @@ func (c *Client) raw(ctx context.Context, r Request) (int, []byte, string, error | |||
| 113 | } | 141 | } |
| 114 | defer resp.Body.Close() | 142 | defer resp.Body.Close() |
| 115 | 143 | ||
| 116 | - data, err := io.ReadAll(resp.Body) | 144 | + data, err := io.ReadAll(io.LimitReader(resp.Body, MaxResponseBytes+1)) |
| 117 | if err != nil { | 145 | if err != nil { |
| 118 | return resp.StatusCode, nil, resp.Header.Get("Content-Type"), err | 146 | return resp.StatusCode, nil, resp.Header.Get("Content-Type"), err |
| 119 | } | 147 | } |
| 148 | + if int64(len(data)) > MaxResponseBytes { | ||
| 149 | + return resp.StatusCode, nil, resp.Header.Get("Content-Type"), | ||
| 150 | + fmt.Errorf("response from %s exceeds %d bytes; refusing to buffer it", c.Host, MaxResponseBytes) | ||
| 151 | + } | ||
| 120 | 152 | ||
| 121 | if resp.StatusCode >= 200 && resp.StatusCode < 300 { | 153 | if resp.StatusCode >= 200 && resp.StatusCode < 300 { |
| 122 | return resp.StatusCode, data, resp.Header.Get("Content-Type"), nil | 154 | return resp.StatusCode, data, resp.Header.Get("Content-Type"), nil |
modified
internal/api/client_test.go +67 -0 | @@ -1,11 +1,13 @@ | ||
| 1 | 1 | package api |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | + "bytes" | |
| 4 | 5 | "context" |
| 5 | 6 | "encoding/json" |
| 6 | 7 | "errors" |
| 7 | 8 | "net/http" |
| 8 | 9 | "net/http/httptest" |
| 10 | + "strings" | |
| 9 | 11 | "testing" |
| 10 | 12 | ) |
| 11 | 13 | |
| @@ -219,3 +221,68 @@ func TestContentsPathEscaping(t *testing.T) { | ||
| 219 | 221 | t.Errorf("path = %q, want %q", gotPath, want) |
| 220 | 222 | } |
| 221 | 223 | } |
| 224 | + | |
| 225 | +// The bearer token rides on every request, so a redirect may not carry it to | |
| 226 | +// another host, and an https origin may not be downgraded to http. | |
| 227 | +func TestRedirectsStayOnHostAndScheme(t *testing.T) { | |
| 228 | + other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 229 | + t.Errorf("the other host received %s %s with Authorization=%q", r.Method, r.URL.Path, r.Header.Get("Authorization")) | |
| 230 | + })) | |
| 231 | + defer other.Close() | |
| 232 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 233 | + switch r.URL.Path { | |
| 234 | + case "/api/v1/elsewhere": | |
| 235 | + http.Redirect(w, r, other.URL+"/api/v1/user", http.StatusFound) | |
| 236 | + case "/api/v1/downgrade": | |
| 237 | + http.Redirect(w, r, "http://"+r.Host+"/api/v1/user", http.StatusFound) | |
| 238 | + case "/api/v1/same": | |
| 239 | + http.Redirect(w, r, "/api/v1/user", http.StatusFound) | |
| 240 | + case "/api/v1/user": | |
| 241 | + _, _ = w.Write([]byte(`{"handle":"rick"}`)) | |
| 242 | + default: | |
| 243 | + w.WriteHeader(http.StatusNotFound) | |
| 244 | + } | |
| 245 | + })) | |
| 246 | + defer srv.Close() | |
| 247 | + c := New(srv.URL, "rickub_pat_x") | |
| 248 | + | |
| 249 | + if _, _, err := c.RawJSON(context.Background(), "GET", "/elsewhere", nil, nil); err == nil || !strings.Contains(err.Error(), "another host") { | |
| 250 | + t.Errorf("cross-host redirect must be refused, got %v", err) | |
| 251 | + } | |
| 252 | + if _, _, err := c.RawJSON(context.Background(), "GET", "/same", nil, nil); err != nil { | |
| 253 | + t.Errorf("same-host redirect should be followed: %v", err) | |
| 254 | + } | |
| 255 | + // httptest is plain http, so exercise the downgrade rule directly. | |
| 256 | + first, _ := http.NewRequest("GET", "https://rickub.com/api/v1/x", nil) | |
| 257 | + next, _ := http.NewRequest("GET", "http://rickub.com/api/v1/x", nil) | |
| 258 | + if err := refuseUnsafeRedirect(next, []*http.Request{first}); err == nil { | |
| 259 | + t.Error("https→http redirect must be refused") | |
| 260 | + } | |
| 261 | + sub, _ := http.NewRequest("GET", "https://api.rickub.com/api/v1/x", nil) | |
| 262 | + if err := refuseUnsafeRedirect(sub, []*http.Request{first}); err == nil { | |
| 263 | + t.Error("redirect to a subdomain must be refused (Go would forward Authorization there)") | |
| 264 | + } | |
| 265 | + same, _ := http.NewRequest("GET", "https://rickub.com/api/v1/y", nil) | |
| 266 | + if err := refuseUnsafeRedirect(same, []*http.Request{first}); err != nil { | |
| 267 | + t.Errorf("same-origin redirect should pass: %v", err) | |
| 268 | + } | |
| 269 | +} | |
| 270 | + | |
| 271 | +// A hostile or broken host cannot make the CLI buffer an unbounded body. | |
| 272 | +func TestResponseBodyIsCapped(t *testing.T) { | |
| 273 | + old := MaxResponseBytes | |
| 274 | + MaxResponseBytes = 1024 | |
| 275 | + t.Cleanup(func() { MaxResponseBytes = old }) | |
| 276 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 277 | + _, _ = w.Write(bytes.Repeat([]byte("x"), 4096)) | |
| 278 | + })) | |
| 279 | + defer srv.Close() | |
| 280 | + c := New(srv.URL, "rickub_pat_x") | |
| 281 | + if _, _, err := c.RawJSON(context.Background(), "GET", "/big", nil, nil); err == nil || !strings.Contains(err.Error(), "exceeds") { | |
| 282 | + t.Errorf("oversized body must be refused, got %v", err) | |
| 283 | + } | |
| 284 | + MaxResponseBytes = 8192 | |
| 285 | + if _, _, err := c.RawJSON(context.Background(), "GET", "/big", nil, nil); err != nil { | |
| 286 | + t.Errorf("body under the cap should be read: %v", err) | |
| 287 | + } | |
| 288 | +} | |
| @@ -1,11 +1,13 @@ | |||
| 1 | package api | 1 | package api |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | + "bytes" | ||
| 4 | "context" | 5 | "context" |
| 5 | "encoding/json" | 6 | "encoding/json" |
| 6 | "errors" | 7 | "errors" |
| 7 | "net/http" | 8 | "net/http" |
| 8 | "net/http/httptest" | 9 | "net/http/httptest" |
| 10 | + "strings" | ||
| 9 | "testing" | 11 | "testing" |
| 10 | ) | 12 | ) |
| 11 | 13 | ||
| @@ -219,3 +221,68 @@ func TestContentsPathEscaping(t *testing.T) { | |||
| 219 | t.Errorf("path = %q, want %q", gotPath, want) | 221 | t.Errorf("path = %q, want %q", gotPath, want) |
| 220 | } | 222 | } |
| 221 | } | 223 | } |
| 224 | + | ||
| 225 | +// The bearer token rides on every request, so a redirect may not carry it to | ||
| 226 | +// another host, and an https origin may not be downgraded to http. | ||
| 227 | +func TestRedirectsStayOnHostAndScheme(t *testing.T) { | ||
| 228 | + other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 229 | + t.Errorf("the other host received %s %s with Authorization=%q", r.Method, r.URL.Path, r.Header.Get("Authorization")) | ||
| 230 | + })) | ||
| 231 | + defer other.Close() | ||
| 232 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 233 | + switch r.URL.Path { | ||
| 234 | + case "/api/v1/elsewhere": | ||
| 235 | + http.Redirect(w, r, other.URL+"/api/v1/user", http.StatusFound) | ||
| 236 | + case "/api/v1/downgrade": | ||
| 237 | + http.Redirect(w, r, "http://"+r.Host+"/api/v1/user", http.StatusFound) | ||
| 238 | + case "/api/v1/same": | ||
| 239 | + http.Redirect(w, r, "/api/v1/user", http.StatusFound) | ||
| 240 | + case "/api/v1/user": | ||
| 241 | + _, _ = w.Write([]byte(`{"handle":"rick"}`)) | ||
| 242 | + default: | ||
| 243 | + w.WriteHeader(http.StatusNotFound) | ||
| 244 | + } | ||
| 245 | + })) | ||
| 246 | + defer srv.Close() | ||
| 247 | + c := New(srv.URL, "rickub_pat_x") | ||
| 248 | + | ||
| 249 | + if _, _, err := c.RawJSON(context.Background(), "GET", "/elsewhere", nil, nil); err == nil || !strings.Contains(err.Error(), "another host") { | ||
| 250 | + t.Errorf("cross-host redirect must be refused, got %v", err) | ||
| 251 | + } | ||
| 252 | + if _, _, err := c.RawJSON(context.Background(), "GET", "/same", nil, nil); err != nil { | ||
| 253 | + t.Errorf("same-host redirect should be followed: %v", err) | ||
| 254 | + } | ||
| 255 | + // httptest is plain http, so exercise the downgrade rule directly. | ||
| 256 | + first, _ := http.NewRequest("GET", "https://rickub.com/api/v1/x", nil) | ||
| 257 | + next, _ := http.NewRequest("GET", "http://rickub.com/api/v1/x", nil) | ||
| 258 | + if err := refuseUnsafeRedirect(next, []*http.Request{first}); err == nil { | ||
| 259 | + t.Error("https→http redirect must be refused") | ||
| 260 | + } | ||
| 261 | + sub, _ := http.NewRequest("GET", "https://api.rickub.com/api/v1/x", nil) | ||
| 262 | + if err := refuseUnsafeRedirect(sub, []*http.Request{first}); err == nil { | ||
| 263 | + t.Error("redirect to a subdomain must be refused (Go would forward Authorization there)") | ||
| 264 | + } | ||
| 265 | + same, _ := http.NewRequest("GET", "https://rickub.com/api/v1/y", nil) | ||
| 266 | + if err := refuseUnsafeRedirect(same, []*http.Request{first}); err != nil { | ||
| 267 | + t.Errorf("same-origin redirect should pass: %v", err) | ||
| 268 | + } | ||
| 269 | +} | ||
| 270 | + | ||
| 271 | +// A hostile or broken host cannot make the CLI buffer an unbounded body. | ||
| 272 | +func TestResponseBodyIsCapped(t *testing.T) { | ||
| 273 | + old := MaxResponseBytes | ||
| 274 | + MaxResponseBytes = 1024 | ||
| 275 | + t.Cleanup(func() { MaxResponseBytes = old }) | ||
| 276 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 277 | + _, _ = w.Write(bytes.Repeat([]byte("x"), 4096)) | ||
| 278 | + })) | ||
| 279 | + defer srv.Close() | ||
| 280 | + c := New(srv.URL, "rickub_pat_x") | ||
| 281 | + if _, _, err := c.RawJSON(context.Background(), "GET", "/big", nil, nil); err == nil || !strings.Contains(err.Error(), "exceeds") { | ||
| 282 | + t.Errorf("oversized body must be refused, got %v", err) | ||
| 283 | + } | ||
| 284 | + MaxResponseBytes = 8192 | ||
| 285 | + if _, _, err := c.RawJSON(context.Background(), "GET", "/big", nil, nil); err != nil { | ||
| 286 | + t.Errorf("body under the cap should be read: %v", err) | ||
| 287 | + } | ||
| 288 | +} | ||
modified
internal/config/config.go +35 -4 | @@ -98,7 +98,7 @@ func Load() (*Config, error) { | ||
| 98 | 98 | |
| 99 | 99 | // LoadFrom reads config from an explicit path (used by tests). |
| 100 | 100 | func LoadFrom(path string) (*Config, error) { |
| 101 | - data, err := os.ReadFile(path) | |
| 101 | + data, err := os.ReadFile(path) // #nosec G304 -- path is the CLI's own config file under $XDG_CONFIG_HOME/$HOME, not user-supplied input | |
| 102 | 102 | if err != nil { |
| 103 | 103 | if errors.Is(err, os.ErrNotExist) { |
| 104 | 104 | return &Config{}, nil |
| @@ -147,16 +147,47 @@ func (c *Config) Save() error { | ||
| 147 | 147 | } |
| 148 | 148 | |
| 149 | 149 | // SaveTo writes config to an explicit path (used by tests). |
| 150 | +// | |
| 151 | +// The write is atomic and the result is always mode 0600: the bytes go to a | |
| 152 | +// fresh 0600 temp file in the same directory, which is then renamed over the | |
| 153 | +// config. A crash mid-write can therefore never leave a truncated file, and a | |
| 154 | +// config that an older tool or a careless chmod left world-readable is fixed | |
| 155 | +// on the next save rather than inherited (os.WriteFile keeps an existing | |
| 156 | +// file's mode). | |
| 150 | 157 | func (c *Config) SaveTo(path string) error { |
| 151 | - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { | |
| 158 | + dir := filepath.Dir(path) | |
| 159 | + if err := os.MkdirAll(dir, 0o700); err != nil { | |
| 152 | 160 | return err |
| 153 | 161 | } |
| 154 | 162 | data, err := yaml.Marshal(c) |
| 155 | 163 | if err != nil { |
| 156 | 164 | return err |
| 157 | 165 | } |
| 158 | - // Write 0600 so the token is not world-readable. | |
| 159 | - return os.WriteFile(path, data, 0o600) | |
| 166 | + tmp, err := os.CreateTemp(dir, ".config-*.yaml") | |
| 167 | + if err != nil { | |
| 168 | + return err | |
| 169 | + } | |
| 170 | + tmpPath := tmp.Name() | |
| 171 | + cleanup := func() { _ = os.Remove(tmpPath) } | |
| 172 | + if err := tmp.Chmod(0o600); err != nil { | |
| 173 | + _ = tmp.Close() | |
| 174 | + cleanup() | |
| 175 | + return err | |
| 176 | + } | |
| 177 | + if _, err := tmp.Write(data); err != nil { | |
| 178 | + _ = tmp.Close() | |
| 179 | + cleanup() | |
| 180 | + return err | |
| 181 | + } | |
| 182 | + if err := tmp.Close(); err != nil { | |
| 183 | + cleanup() | |
| 184 | + return err | |
| 185 | + } | |
| 186 | + if err := os.Rename(tmpPath, path); err != nil { | |
| 187 | + cleanup() | |
| 188 | + return err | |
| 189 | + } | |
| 190 | + return nil | |
| 160 | 191 | } |
| 161 | 192 | |
| 162 | 193 | // TokenFor returns the stored token minted for host, or "" if none is stored |
| @@ -98,7 +98,7 @@ func Load() (*Config, error) { | |||
| 98 | 98 | ||
| 99 | // LoadFrom reads config from an explicit path (used by tests). | 99 | // LoadFrom reads config from an explicit path (used by tests). |
| 100 | func LoadFrom(path string) (*Config, error) { | 100 | func LoadFrom(path string) (*Config, error) { |
| 101 | - data, err := os.ReadFile(path) | 101 | + data, err := os.ReadFile(path) // #nosec G304 -- path is the CLI's own config file under $XDG_CONFIG_HOME/$HOME, not user-supplied input |
| 102 | if err != nil { | 102 | if err != nil { |
| 103 | if errors.Is(err, os.ErrNotExist) { | 103 | if errors.Is(err, os.ErrNotExist) { |
| 104 | return &Config{}, nil | 104 | return &Config{}, nil |
| @@ -147,16 +147,47 @@ func (c *Config) Save() error { | |||
| 147 | } | 147 | } |
| 148 | 148 | ||
| 149 | // SaveTo writes config to an explicit path (used by tests). | 149 | // SaveTo writes config to an explicit path (used by tests). |
| 150 | +// | ||
| 151 | +// The write is atomic and the result is always mode 0600: the bytes go to a | ||
| 152 | +// fresh 0600 temp file in the same directory, which is then renamed over the | ||
| 153 | +// config. A crash mid-write can therefore never leave a truncated file, and a | ||
| 154 | +// config that an older tool or a careless chmod left world-readable is fixed | ||
| 155 | +// on the next save rather than inherited (os.WriteFile keeps an existing | ||
| 156 | +// file's mode). | ||
| 150 | func (c *Config) SaveTo(path string) error { | 157 | func (c *Config) SaveTo(path string) error { |
| 151 | - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { | 158 | + dir := filepath.Dir(path) |
| 159 | + if err := os.MkdirAll(dir, 0o700); err != nil { | ||
| 152 | return err | 160 | return err |
| 153 | } | 161 | } |
| 154 | data, err := yaml.Marshal(c) | 162 | data, err := yaml.Marshal(c) |
| 155 | if err != nil { | 163 | if err != nil { |
| 156 | return err | 164 | return err |
| 157 | } | 165 | } |
| 158 | - // Write 0600 so the token is not world-readable. | 166 | + tmp, err := os.CreateTemp(dir, ".config-*.yaml") |
| 159 | - return os.WriteFile(path, data, 0o600) | 167 | + if err != nil { |
| 168 | + return err | ||
| 169 | + } | ||
| 170 | + tmpPath := tmp.Name() | ||
| 171 | + cleanup := func() { _ = os.Remove(tmpPath) } | ||
| 172 | + if err := tmp.Chmod(0o600); err != nil { | ||
| 173 | + _ = tmp.Close() | ||
| 174 | + cleanup() | ||
| 175 | + return err | ||
| 176 | + } | ||
| 177 | + if _, err := tmp.Write(data); err != nil { | ||
| 178 | + _ = tmp.Close() | ||
| 179 | + cleanup() | ||
| 180 | + return err | ||
| 181 | + } | ||
| 182 | + if err := tmp.Close(); err != nil { | ||
| 183 | + cleanup() | ||
| 184 | + return err | ||
| 185 | + } | ||
| 186 | + if err := os.Rename(tmpPath, path); err != nil { | ||
| 187 | + cleanup() | ||
| 188 | + return err | ||
| 189 | + } | ||
| 190 | + return nil | ||
| 160 | } | 191 | } |
| 161 | 192 | ||
| 162 | // TokenFor returns the stored token minted for host, or "" if none is stored | 193 | // TokenFor returns the stored token minted for host, or "" if none is stored |
modified
internal/config/config_test.go +34 -0 | @@ -271,3 +271,37 @@ func TestLegacyTokenDefaultsToProductionHostAndNeverOverridesAnEntry(t *testing. | ||
| 271 | 271 | t.Error("stale legacy token was kept") |
| 272 | 272 | } |
| 273 | 273 | } |
| 274 | + | |
| 275 | +// Saving is atomic and always leaves the file at 0600, even when an older tool | |
| 276 | +// or a stray chmod left a world-readable config behind. | |
| 277 | +func TestSaveFixesModeAndLeavesNoTempFiles(t *testing.T) { | |
| 278 | + dir := t.TempDir() | |
| 279 | + path := filepath.Join(dir, "config.yaml") | |
| 280 | + if err := os.WriteFile(path, []byte("host: https://rickub.com\n"), 0o644); err != nil { | |
| 281 | + t.Fatal(err) | |
| 282 | + } | |
| 283 | + c := &Config{} | |
| 284 | + c.SetToken("https://rickub.com", "rickub_pat_abc") | |
| 285 | + if err := c.SaveTo(path); err != nil { | |
| 286 | + t.Fatalf("SaveTo: %v", err) | |
| 287 | + } | |
| 288 | + info, err := os.Stat(path) | |
| 289 | + if err != nil { | |
| 290 | + t.Fatal(err) | |
| 291 | + } | |
| 292 | + if perm := info.Mode().Perm(); perm != 0o600 { | |
| 293 | + t.Errorf("perm = %o after save over a 0644 file, want 600", perm) | |
| 294 | + } | |
| 295 | + entries, _ := os.ReadDir(dir) | |
| 296 | + if len(entries) != 1 { | |
| 297 | + names := []string{} | |
| 298 | + for _, e := range entries { | |
| 299 | + names = append(names, e.Name()) | |
| 300 | + } | |
| 301 | + t.Errorf("temp file left behind: %v", names) | |
| 302 | + } | |
| 303 | + out, err := LoadFrom(path) | |
| 304 | + if err != nil || out.TokenFor("https://rickub.com") != "rickub_pat_abc" { | |
| 305 | + t.Errorf("round-trip after atomic save: %v %+v", err, out) | |
| 306 | + } | |
| 307 | +} | |
| @@ -271,3 +271,37 @@ func TestLegacyTokenDefaultsToProductionHostAndNeverOverridesAnEntry(t *testing. | |||
| 271 | t.Error("stale legacy token was kept") | 271 | t.Error("stale legacy token was kept") |
| 272 | } | 272 | } |
| 273 | } | 273 | } |
| 274 | + | ||
| 275 | +// Saving is atomic and always leaves the file at 0600, even when an older tool | ||
| 276 | +// or a stray chmod left a world-readable config behind. | ||
| 277 | +func TestSaveFixesModeAndLeavesNoTempFiles(t *testing.T) { | ||
| 278 | + dir := t.TempDir() | ||
| 279 | + path := filepath.Join(dir, "config.yaml") | ||
| 280 | + if err := os.WriteFile(path, []byte("host: https://rickub.com\n"), 0o644); err != nil { | ||
| 281 | + t.Fatal(err) | ||
| 282 | + } | ||
| 283 | + c := &Config{} | ||
| 284 | + c.SetToken("https://rickub.com", "rickub_pat_abc") | ||
| 285 | + if err := c.SaveTo(path); err != nil { | ||
| 286 | + t.Fatalf("SaveTo: %v", err) | ||
| 287 | + } | ||
| 288 | + info, err := os.Stat(path) | ||
| 289 | + if err != nil { | ||
| 290 | + t.Fatal(err) | ||
| 291 | + } | ||
| 292 | + if perm := info.Mode().Perm(); perm != 0o600 { | ||
| 293 | + t.Errorf("perm = %o after save over a 0644 file, want 600", perm) | ||
| 294 | + } | ||
| 295 | + entries, _ := os.ReadDir(dir) | ||
| 296 | + if len(entries) != 1 { | ||
| 297 | + names := []string{} | ||
| 298 | + for _, e := range entries { | ||
| 299 | + names = append(names, e.Name()) | ||
| 300 | + } | ||
| 301 | + t.Errorf("temp file left behind: %v", names) | ||
| 302 | + } | ||
| 303 | + out, err := LoadFrom(path) | ||
| 304 | + if err != nil || out.TokenFor("https://rickub.com") != "rickub_pat_abc" { | ||
| 305 | + t.Errorf("round-trip after atomic save: %v %+v", err, out) | ||
| 306 | + } | ||
| 307 | +} | ||