Skip to content
Open
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ jobs:
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: v2.7.2
# Version omitted to use action's default (tracks Go compatibility)
args: --timeout=5m
Comment on lines 84 to 86
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using golangci-lint version latest makes CI non-deterministic and can break builds unexpectedly when new linter releases add checks or change defaults. Please pin this to a specific known-good golangci-lint version (and update it intentionally when desired).

Copilot uses AI. Check for mistakes.

# Posts a sticky coverage comment to PRs (updates in place, details collapsed)
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
bin/
dist/
completions/
.DS_Store
*.log
nohup.out
Expand Down
24 changes: 15 additions & 9 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ before:
hooks:
- go mod tidy
- go mod download
- mkdir -p completions
- sh -c "go run ./cmd/armis-cli completion bash > completions/armis-cli.bash"
- sh -c "go run ./cmd/armis-cli completion zsh > completions/_armis-cli"
- sh -c "go run ./cmd/armis-cli completion fish > completions/armis-cli.fish"
- sh -c "go run ./cmd/armis-cli completion powershell > completions/armis-cli.ps1"

builds:
- id: armis-cli
Expand Down Expand Up @@ -42,6 +47,7 @@ archives:
- LICENSE*
- README.md
- docs/**/*
- completions/*

checksum:
name_template: "armis-cli-checksums.txt"
Expand Down Expand Up @@ -89,29 +95,29 @@ release:
mode: replace
header: |
## Armis CLI {{ .Tag }}

Enterprise-grade CLI tool for static application security scanning.

### Installation

**Quick Install Script:**
```bash
curl -sSL https://raw.githubusercontent.com/ArmisSecurity/armis-cli/main/scripts/install.sh | bash
```

**Windows (PowerShell):**
```powershell
irm https://raw.githubusercontent.com/ArmisSecurity/armis-cli/main/scripts/install.ps1 | iex
```

**Go Install:**
```bash
go install github.com/ArmisSecurity/armis-cli/cmd/armis-cli@latest
```

**Manual Download:**
Download the appropriate binary for your platform below.

### Verification
All binaries are signed with cosign. To verify:
```bash
Expand All @@ -123,9 +129,9 @@ release:
```
footer: |
---

**Full Changelog**: https://github.com/ArmisSecurity/armis-cli/compare/{{ .PreviousTag }}...{{ .Tag }}

For issues or questions, visit: https://github.com/ArmisSecurity/armis-cli/issues

snapshot:
Expand Down
6 changes: 6 additions & 0 deletions cmd/armis-cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package main
import (
"os"

"github.com/ArmisSecurity/armis-cli/internal/cli"
"github.com/ArmisSecurity/armis-cli/internal/cmd"
)

Expand All @@ -15,7 +16,12 @@ var (

func main() {
cmd.SetVersion(version, commit, date)
// Initialize colors early with auto-detection as fallback.
// This handles cases where PersistentPreRunE doesn't fire (e.g., flag parsing errors).
// The actual --color flag value will override this in PersistentPreRunE.
cli.InitColors(cli.ColorModeAuto)
if err := cmd.Execute(); err != nil {
cli.PrintError(err.Error())
os.Exit(1)
}
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ require (
github.com/mattn/go-runewidth v0.0.19
github.com/schollz/progressbar/v3 v3.19.0
github.com/spf13/cobra v1.10.2
golang.org/x/term v0.38.0
)

require (
Expand All @@ -24,6 +25,5 @@ require (
github.com/spf13/pflag v1.0.10 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/term v0.38.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
)
38 changes: 30 additions & 8 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,10 @@ type IngestOptions struct {
GenerateVEX bool
}

// StatusCallback is called on each poll with the current scan status.
// It allows callers to react to status changes (e.g., updating a spinner).
type StatusCallback func(status model.IngestStatusData)

// StartIngest uploads an artifact for scanning and returns the scan ID.
func (c *Client) StartIngest(ctx context.Context, opts IngestOptions) (string, error) {
// Validate upload size for defense-in-depth
Expand Down Expand Up @@ -264,8 +268,12 @@ func (c *Client) StartIngest(ctx context.Context, opts IngestOptions) (string, e
elapsed, formatBytes(opts.Size), resp.Status, strings.TrimSpace(string(bodyBytes)))
}

bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, MaxAPIResponseSize))
if err != nil {
return "", fmt.Errorf("failed to read response body: %w", err)
}
var result model.IngestUploadResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
if err := json.Unmarshal(bodyBytes, &result); err != nil {
return "", fmt.Errorf("failed to decode response: %w", err)
}

Expand Down Expand Up @@ -296,20 +304,25 @@ func (c *Client) GetIngestStatus(ctx context.Context, tenantID, scanID string) (
defer resp.Body.Close() //nolint:errcheck // response body read-only

if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, MaxAPIResponseSize))
return nil, fmt.Errorf("get status failed with status %d: %s", resp.StatusCode, string(bodyBytes))
}

bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, MaxAPIResponseSize))
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
var result model.IngestStatusResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
if err := json.Unmarshal(bodyBytes, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}

return &result, nil
}

// WaitForIngest polls until the ingestion is complete or times out.
func (c *Client) WaitForIngest(ctx context.Context, tenantID, scanID string, pollInterval time.Duration, timeout time.Duration) (*model.IngestStatusData, error) {
// If onStatus is non-nil, it is called on each poll with the current status.
func (c *Client) WaitForIngest(ctx context.Context, tenantID, scanID string, pollInterval time.Duration, timeout time.Duration, onStatus StatusCallback) (*model.IngestStatusData, error) {
if timeout <= 0 {
timeout = 60 * time.Minute
}
Expand Down Expand Up @@ -338,6 +351,11 @@ func (c *Client) WaitForIngest(ctx context.Context, tenantID, scanID string, pol
}

status := statusResp.Data[0]

if onStatus != nil {
onStatus(status)
}

statusUpper := strings.ToUpper(status.ScanStatus)

if statusUpper == "COMPLETED" || statusUpper == "FAILED" {
Expand Down Expand Up @@ -379,11 +397,11 @@ func (c *Client) FetchNormalizedResults(ctx context.Context, tenantID, scanID st
defer resp.Body.Close() //nolint:errcheck // response body read-only

if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, MaxAPIResponseSize))
return nil, fmt.Errorf("fetch results failed with status %d: %s", resp.StatusCode, string(bodyBytes))
}

bodyBytes, err := io.ReadAll(resp.Body)
bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, MaxAPIResponseSize))
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
Expand Down Expand Up @@ -444,12 +462,16 @@ func (c *Client) GetScanResult(ctx context.Context, scanID string) (*model.ScanR
defer resp.Body.Close() //nolint:errcheck // response body read-only

if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, MaxAPIResponseSize))
return nil, fmt.Errorf("get scan failed with status %d: %s", resp.StatusCode, string(bodyBytes))
}

bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, MaxAPIResponseSize))
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
var result model.ScanResult
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
if err := json.Unmarshal(bodyBytes, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}

Expand Down
64 changes: 56 additions & 8 deletions internal/api/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -934,7 +934,7 @@ func TestClient_WaitForIngest(t *testing.T) {
t.Fatalf("NewClient failed: %v", err)
}

result, err := client.WaitForIngest(context.Background(), "tenant-456", "scan-123", 10*time.Millisecond, 5*time.Second)
result, err := client.WaitForIngest(context.Background(), "tenant-456", "scan-123", 10*time.Millisecond, 5*time.Second, nil)

if err != nil {
t.Fatalf("WaitForIngest failed: %v", err)
Expand Down Expand Up @@ -969,7 +969,7 @@ func TestClient_WaitForIngest(t *testing.T) {
t.Fatalf("NewClient failed: %v", err)
}

_, err = client.WaitForIngest(context.Background(), "tenant-456", "scan-123", 10*time.Millisecond, 5*time.Second)
_, err = client.WaitForIngest(context.Background(), "tenant-456", "scan-123", 10*time.Millisecond, 5*time.Second, nil)

if err == nil {
t.Fatal("Expected error for FAILED status")
Expand Down Expand Up @@ -1000,7 +1000,7 @@ func TestClient_WaitForIngest(t *testing.T) {
t.Fatalf("NewClient failed: %v", err)
}

result, err := client.WaitForIngest(context.Background(), "tenant-456", "scan-123", 10*time.Millisecond, 5*time.Second)
result, err := client.WaitForIngest(context.Background(), "tenant-456", "scan-123", 10*time.Millisecond, 5*time.Second, nil)

if err != nil {
t.Fatalf("WaitForIngest failed: %v", err)
Expand Down Expand Up @@ -1030,7 +1030,7 @@ func TestClient_WaitForIngest(t *testing.T) {
t.Fatalf("NewClient failed: %v", err)
}

_, err = client.WaitForIngest(context.Background(), "tenant-456", "scan-123", 10*time.Millisecond, 50*time.Millisecond)
_, err = client.WaitForIngest(context.Background(), "tenant-456", "scan-123", 10*time.Millisecond, 50*time.Millisecond, nil)

if err == nil {
t.Fatal("Expected timeout error")
Expand Down Expand Up @@ -1067,7 +1067,7 @@ func TestClient_WaitForIngest(t *testing.T) {
cancel()
}()

_, err = client.WaitForIngest(ctx, "tenant-456", "scan-123", 10*time.Millisecond, 5*time.Second)
_, err = client.WaitForIngest(ctx, "tenant-456", "scan-123", 10*time.Millisecond, 5*time.Second, nil)

if err == nil {
t.Fatal("Expected context cancellation error")
Expand All @@ -1088,7 +1088,7 @@ func TestClient_WaitForIngest(t *testing.T) {
t.Fatalf("NewClient failed: %v", err)
}

_, err = client.WaitForIngest(context.Background(), "tenant-456", "scan-123", 10*time.Millisecond, 5*time.Second)
_, err = client.WaitForIngest(context.Background(), "tenant-456", "scan-123", 10*time.Millisecond, 5*time.Second, nil)

if err == nil {
t.Fatal("Expected error for empty status data")
Expand All @@ -1109,7 +1109,7 @@ func TestClient_WaitForIngest(t *testing.T) {
t.Fatalf("NewClient failed: %v", err)
}

_, err = client.WaitForIngest(context.Background(), "tenant-456", "scan-123", 10*time.Millisecond, 100*time.Millisecond)
_, err = client.WaitForIngest(context.Background(), "tenant-456", "scan-123", 10*time.Millisecond, 100*time.Millisecond, nil)

if err == nil {
t.Fatal("Expected error for failed status check")
Expand All @@ -1136,7 +1136,7 @@ func TestClient_WaitForIngest(t *testing.T) {
t.Fatalf("NewClient failed: %v", err)
}

result, err := client.WaitForIngest(context.Background(), "tenant-456", "scan-123", 10*time.Millisecond, 5*time.Second)
result, err := client.WaitForIngest(context.Background(), "tenant-456", "scan-123", 10*time.Millisecond, 5*time.Second, nil)

if err != nil {
t.Fatalf("WaitForIngest failed: %v", err)
Expand All @@ -1145,6 +1145,54 @@ func TestClient_WaitForIngest(t *testing.T) {
t.Fatal("Expected non-nil result")
}
})

t.Run("invokes status callback on each poll", func(t *testing.T) {
callCount := 0
var receivedStatuses []string
server := testutil.NewTestServer(t, func(w http.ResponseWriter, _ *http.Request) {
callCount++
var status string
switch {
case callCount <= 1:
status = "QUEUED"
case callCount <= 2:
status = "PROCESSING"
default:
status = testStatusCompleted
}
response := model.IngestStatusResponse{
Data: []model.IngestStatusData{
{ScanID: "scan-123", ScanStatus: status, TenantID: "tenant-456"},
},
}
testutil.JSONResponse(t, w, http.StatusOK, response)
})

httpClient := httpclient.NewClient(httpclient.Config{Timeout: 5 * time.Second})
client, err := NewClient(server.URL, testutil.NewTestAuthProvider("token123"), false, 0, WithHTTPClient(httpClient))
if err != nil {
t.Fatalf("NewClient failed: %v", err)
}

result, err := client.WaitForIngest(context.Background(), "tenant-456", "scan-123",
10*time.Millisecond, 5*time.Second,
func(status model.IngestStatusData) {
receivedStatuses = append(receivedStatuses, status.ScanStatus)
})

if err != nil {
t.Fatalf("WaitForIngest failed: %v", err)
}
if result.ScanStatus != testStatusCompleted {
t.Errorf("Expected status %s, got %s", testStatusCompleted, result.ScanStatus)
}
if len(receivedStatuses) < 3 {
t.Errorf("Expected at least 3 callback invocations, got %d", len(receivedStatuses))
}
if len(receivedStatuses) > 0 && receivedStatuses[0] != "QUEUED" {
t.Errorf("Expected first status QUEUED, got %s", receivedStatuses[0])
}
})
}

func TestClient_WaitForScan(t *testing.T) {
Expand Down
Loading
Loading