From 133a00ef42052c2abf65f7ec161c9b27a2d603ae Mon Sep 17 00:00:00 2001 From: Rushabh Thakkar Date: Mon, 27 Jul 2026 13:44:31 +0530 Subject: [PATCH 1/2] feat(scan): add scan status subcommand [PPSC-1127] New `armis-cli scan status [scan_id]` reports the current state of an initiated scan via GET /api/v1/ingest/status/. Handles every documented ArtifactScanStatus enum value (PENDING_UPLOAD, UPLOADED, INITIATED, IN_PROGRESS, COMPLETED, FAILED, STOPPED) with a state-specific hint line. When invoked without a scan_id, the command falls back to the most recent scan initiated locally for the current (base_url, tenant_id) pair. Every scan repo/image/sbom now records its scan_id in ~/.armis/scan-history.json (0600, capped at 20 entries) so the fallback works out of the box. - Guardrails: --format restricted to human/json, HTTPS enforcement inherited from the shared API client, per-tenant scoping to prevent cross-tenant leakage, and 404/403/422 errors surfaced with actionable hints instead of raw HTTP bodies. - Documentation: README section, updated help examples on `scan` and root. - Tests: unit coverage for all 7 status values, JSON envelope shape, history fallback, cross-env scoping, empty-data / 4xx paths, and history package round-trip / dedup / trimming behavior. --- CLAUDE.md | 4 + README.md | 26 +- docs/SSO-SETUP.md | 8 +- internal/api/client.go | 8 + internal/cmd/root.go | 5 +- internal/cmd/scan.go | 6 +- internal/cmd/scan_status.go | 313 ++++++++++++++++++++++ internal/cmd/scan_status_test.go | 364 ++++++++++++++++++++++++++ internal/scan/history/history.go | 238 +++++++++++++++++ internal/scan/history/history_test.go | 276 +++++++++++++++++++ internal/scan/image/image.go | 1 + internal/scan/record.go | 31 +++ internal/scan/repo/repo.go | 1 + internal/scan/sbom/sbom.go | 1 + 14 files changed, 1274 insertions(+), 8 deletions(-) create mode 100644 internal/cmd/scan_status.go create mode 100644 internal/cmd/scan_status_test.go create mode 100644 internal/scan/history/history.go create mode 100644 internal/scan/history/history_test.go create mode 100644 internal/scan/record.go diff --git a/CLAUDE.md b/CLAUDE.md index 1b33821..e8a1030 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,22 +86,26 @@ go test -v ./internal/output/... -run TestHumanFormatter ### Environment Variables **Authentication:** + - `ARMIS_CLIENT_ID` - Client ID for JWT authentication (recommended) - `ARMIS_CLIENT_SECRET` - Client secret for JWT authentication - `ARMIS_API_TOKEN` - API token for Basic authentication (fallback) - `ARMIS_TENANT_ID` - Tenant identifier (required only with Basic auth; JWT extracts it from token) **API Configuration:** + - `ARMIS_API_URL` - Override base URL for Armis API (advanced; defaults based on --dev flag) - `ARMIS_REGION` - Override Armis cloud region (equivalent to `--region`; used for region-aware authentication) - `ARMIS_LOCAL_S3_ENDPOINT` - Comma-separated list of host:port entries for mock S3 services in local development (e.g., `awsmock-dev:4566,localstack:4566`). **SECURITY:** Only enabled when `ARMIS_API_URL` is localhost or RFC 1918 private IP. Allows HTTP access to configured hosts for SBOM/VEX downloads. Blocked for all remote/cloud endpoints. **Output Configuration:** + - `ARMIS_FORMAT` - Default output format - `ARMIS_PAGE_LIMIT` - Results pagination size - `ARMIS_THEME` - Terminal background theme: auto, dark, light (default: auto) **Other:** + - `ARMIS_NO_UPDATE_CHECK` - Disable automatic version update checking When both JWT and Basic credentials are configured, JWT takes precedence. diff --git a/README.md b/README.md index bae5861..a724131 100644 --- a/README.md +++ b/README.md @@ -557,6 +557,31 @@ armis-cli scan image nginx:latest --pull=always armis-cli scan image nginx:latest --pull=never ``` +### Check Scan Status + +Fetch the current status of a scan initiated with `scan repo`, `scan image`, or `scan sbom`. + +```bash +armis-cli scan status [scan_id] +``` + +When no `scan_id` is supplied, the command reuses the most recent scan initiated on this machine for the current tenant. Every successful scan records its `scan_id` locally in `~/.armis/scan-history.json` (created 0600), so re-checking a scan does not require copying an ID. + +**Examples:** + +```bash +# Look up a specific scan +armis-cli scan status a1b2c3d4-... + +# Re-check the most recently initiated scan on this machine +armis-cli scan status + +# Machine-readable output +armis-cli scan status --format json +``` + +`scan status` reports every state the API can return: `PENDING_UPLOAD`, `UPLOADED`, `INITIATED`, `IN_PROGRESS`, `COMPLETED`, `FAILED`, `STOPPED`. + ### Other Commands ```bash @@ -1019,7 +1044,6 @@ Before users can sign in with SSO, an IT admin registers the tenant's identity p | `ARMIS_THEME` | Terminal background theme: auto, dark, light (default: auto) | | `ARMIS_NO_UPDATE_CHECK` | Disable automatic update checking | - --- ## Security Considerations diff --git a/docs/SSO-SETUP.md b/docs/SSO-SETUP.md index 95427b1..2fa2095 100644 --- a/docs/SSO-SETUP.md +++ b/docs/SSO-SETUP.md @@ -78,7 +78,7 @@ armis-cli auth setup \ --client-secret "$ARMIS_CLIENT_SECRET" ``` -The command auto-detects your region and tenant from your credentials, +The command auto-detects your region and tenant from your credentials, then walks you through the values from Step 1: IdP type, issuer, client ID, client secret, and group claim. @@ -112,8 +112,8 @@ The first Armis command a developer runs then opens the browser for sign-in ## Verifying and updating Re-running `armis-cli auth setup` fetches the existing configuration, shows the -current values (secrets excluded) and lets you **edit it in place** — for example -to rotate the secret or change a group mapping. +current values (secrets excluded) and lets you **edit it in place** — for example +to rotate the secret or change a group mapping. This both verifies the registration and is how you update it later. For an end-to-end check, have a developer run `armis-cli auth login` and confirm @@ -126,5 +126,3 @@ sign-in completes with the expected role. | Setup rejected as unauthorized | Wrong/expired API credentials. Re-copy them from `/settings/api-access`. | | Developer sign-in fails with "access denied" | The user isn't in any mapped group. Add their IdP group under `admin` or `developer` (Step 2). | | Redirect / callback error during sign-in | The redirect URI in your IdP doesn't match your region host (Step 1). | - - diff --git a/internal/api/client.go b/internal/api/client.go index cfbee88..9059c0e 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -269,6 +269,14 @@ func (c *Client) IsDebug() bool { return c.debug } +// BaseURL returns the API base URL the client was configured with. Callers +// use it as the scoping key for per-environment on-disk state (e.g. the +// scan-history store) so a token issued for one Armis environment can never +// look up scans from another. +func (c *Client) BaseURL() string { + return c.baseURL +} + // setAuthHeader sets the Authorization header on a request, but only if the // request URL uses HTTPS (or localhost for testing). This prevents credential // exposure over insecure channels. diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 64764d8..06ee635 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -90,7 +90,10 @@ var rootCmd = &cobra.Command{ armis-cli scan image nginx:latest # Scan with specific failure threshold - armis-cli scan repo . --fail-on HIGH,CRITICAL`, + armis-cli scan repo . --fail-on HIGH,CRITICAL + + # Re-check the last scan's status (or look up a specific scan_id) + armis-cli scan status`, Version: version, SilenceUsage: true, SilenceErrors: true, diff --git a/internal/cmd/scan.go b/internal/cmd/scan.go index dc1d978..9a7d114 100644 --- a/internal/cmd/scan.go +++ b/internal/cmd/scan.go @@ -48,7 +48,11 @@ var scanCmd = &cobra.Command{ armis-cli scan image myapp:latest # Scan with SBOM generation - armis-cli scan repo . --sbom --sbom-output sbom.json`, + armis-cli scan repo . --sbom --sbom-output sbom.json + + # Check the status of the last scan (or a specific scan_id) + armis-cli scan status + armis-cli scan status a1b2c3d4-...`, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { // Call root command's PersistentPreRunE to initialize colors and update checking // We reference rootCmd directly since cmd.Parent() would return scanCmd for subcommands diff --git a/internal/cmd/scan_status.go b/internal/cmd/scan_status.go new file mode 100644 index 0000000..d959fc6 --- /dev/null +++ b/internal/cmd/scan_status.go @@ -0,0 +1,313 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + "time" + + "github.com/ArmisSecurity/armis-cli/internal/api" + "github.com/ArmisSecurity/armis-cli/internal/model" + "github.com/ArmisSecurity/armis-cli/internal/output" + "github.com/ArmisSecurity/armis-cli/internal/scan/history" + "github.com/spf13/cobra" +) + +// statusFormat is the output format for `scan status`. Defaults to "human" +// but may be flipped to "json" to feed downstream tooling. +var statusFormat string + +// statusFormats enumerates the accepted values for --format on the status +// subcommand. We keep the surface narrower than the sibling scan commands +// on purpose: SARIF/JUnit make no sense for a single status record. +var statusFormats = []string{statusFormatHuman, statusFormatJSON} + +const ( + statusFormatHuman = "human" + statusFormatJSON = "json" + + // Status enum values worth branching on for terminal styling. + statusValCompleted = "COMPLETED" + statusValFailed = "FAILED" +) + +// scanStatusCmd is `armis-cli scan status [scan_id]`. It fetches the current +// state of a scan from `GET /api/v1/ingest/status/`. +// +// The scan_id argument is optional: with no argument, the command reads the +// most recently initiated scan for the current (base_url, tenant_id) out of +// the on-disk scan-history store (`~/.armis/scan-history.json`) that +// `scan repo`, `scan image`, and `scan sbom` populate. This lets developers +// re-check a scan without cutting and pasting the ID. +var scanStatusCmd = &cobra.Command{ + Use: "status [scan_id]", + Short: "Fetch the status of a scan", + Long: `Fetch the current status of a scan initiated via 'armis-cli scan'. + +When invoked without a scan_id, the command uses the most recent scan_id +recorded locally for the current (base URL, tenant) pair. Every successful +'scan repo', 'scan image', and 'scan sbom' automatically records its +scan_id in ~/.armis/scan-history.json (created 0600) so that this fallback +works out of the box.`, + Example: ` # Look up a specific scan + $ armis-cli scan status a1b2c3d4-... + + # Re-check the most recently initiated scan on this machine + $ armis-cli scan status + + # Machine-readable output + $ armis-cli scan status --format json`, + Args: cobra.MaximumNArgs(1), + RunE: runScanStatus, +} + +// runScanStatus is factored out of the cobra.Command struct so it can be +// exercised directly from tests without going through Execute(). +func runScanStatus(cmd *cobra.Command, args []string) error { + format := strings.ToLower(strings.TrimSpace(statusFormat)) + if !isValidStatusFormat(format) { + return fmt.Errorf("invalid --format value %q: must be one of %v", statusFormat, statusFormats) + } + + authProvider, err := getAuthProvider(cmd.Context()) + if err != nil { + return err + } + if authProvider == nil { + return fmt.Errorf("internal error: nil auth provider") + } + + tid, err := authProvider.GetTenantID(cmd.Context()) + if err != nil { + return err + } + + baseURL := resolveDataPlaneURL(cmd.Context(), authProvider) + client, err := api.NewClient(baseURL, authProvider, debug, time.Duration(uploadTimeout)*time.Minute, + clientOptionsForBaseURL(baseURL)...) + if err != nil { + return fmt.Errorf("failed to create API client: %w", err) + } + + scanID, historyEntry, err := resolveScanID(args, baseURL, tid) + if err != nil { + return err + } + + ctx, cancel := NewSignalContext() + defer cancel() + + statusResp, err := client.GetIngestStatus(ctx, tid, scanID) + if err != nil { + return translateStatusError(err, scanID) + } + if statusResp == nil || len(statusResp.Data) == 0 { + return fmt.Errorf("no status data returned for scan %s (check the scan_id and tenant)", scanID) + } + + data := statusResp.Data[0] + + return renderStatus(cmd.OutOrStdout(), format, data, historyEntry) +} + +// resolveScanID returns the scan_id the user wants status for. When one is +// supplied on the command line it wins; otherwise we fall back to the newest +// entry in the history store for the (baseURL, tenantID) pair. When the +// fallback fails we return a descriptive error so the user knows their +// options. +func resolveScanID(args []string, baseURL, tenantID string) (string, *history.Entry, error) { + if len(args) == 1 { + id := strings.TrimSpace(args[0]) + if id == "" { + return "", nil, errors.New("scan_id argument is empty; pass a non-empty scan_id or omit the argument to use the last scan on this machine") + } + return id, nil, nil + } + + entry, err := history.NewStore().Latest(baseURL, tenantID) + if err != nil || entry == nil { + return "", nil, fmt.Errorf( + "no scan_id provided and no recent scan recorded for tenant %s on %s. "+ + "Run 'armis-cli scan repo|image|sbom' first, or pass an explicit scan_id", + tenantID, baseURL) + } + return entry.ScanID, entry, nil +} + +// translateStatusError wraps API errors from GetIngestStatus with more +// actionable text. The upstream error already carries the raw HTTP body, +// but a "check the scan_id" hint on 404 is worth a lot more than the body +// alone. +func translateStatusError(err error, scanID string) error { + if err == nil { + return nil + } + msg := err.Error() + switch { + case strings.Contains(msg, "status 404"): + return fmt.Errorf("scan %s not found (check the scan_id and that the tenant matches)", scanID) + case strings.Contains(msg, "status 403"): + return fmt.Errorf("access denied for scan %s: your role is not permitted for this endpoint", scanID) + case strings.Contains(msg, "status 422"): + return fmt.Errorf("invalid scan_id %q: %w", scanID, err) + default: + return fmt.Errorf("failed to fetch scan status: %w", err) + } +} + +// isValidStatusFormat reports whether f is one of the values accepted by +// --format. +func isValidStatusFormat(f string) bool { + for _, v := range statusFormats { + if f == v { + return true + } + } + return false +} + +// scanStatusJSON is the JSON envelope emitted when --format=json. It embeds +// the raw API record so downstream tools can rely on the same field names +// as the server response, plus a top-level `scan_id` for convenience. +type scanStatusJSON struct { + ScanID string `json:"scan_id"` + FromCache bool `json:"from_scan_history,omitempty"` + StatusData model.IngestStatusData `json:"status"` +} + +// renderStatus writes the status record in the requested format. Human +// output is the default; JSON mirrors the API record verbatim. +func renderStatus(w io.Writer, format string, data model.IngestStatusData, historyEntry *history.Entry) error { + if format == statusFormatJSON { + payload := scanStatusJSON{ + ScanID: data.ScanID, + FromCache: historyEntry != nil, + StatusData: data, + } + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(payload) + } + renderHumanStatus(w, data, historyEntry) + return nil +} + +// renderHumanStatus prints the record as a small, labeled key/value block. +// The keys align to a fixed 18-column gutter so the values line up under +// each other in every terminal. +func renderHumanStatus(w io.Writer, d model.IngestStatusData, historyEntry *history.Entry) { + styles := output.GetStyles() + + statusVal := strings.ToUpper(d.ScanStatus) + statusRendered := styles.Bold.Render(statusVal) + // Highlight terminal states in matching accent colors: green for + // completed, red for failed. Anything else keeps the plain bold styling. + switch statusVal { + case statusValCompleted: + statusRendered = styles.StatusComplete.Render(statusVal) + case statusValFailed: + statusRendered = styles.CriticalBadge.Render(" " + statusVal + " ") + } + + writeLine := func(label, value string) { + _, _ = fmt.Fprintf(w, " %-18s %s\n", styles.MutedText.Render(label), value) + } + + _, _ = fmt.Fprintln(w, styles.Bold.Render("Scan Status")) + _, _ = fmt.Fprintln(w) + writeLine("Scan ID:", styles.ScanID.Render(d.ScanID)) + writeLine("Status:", statusRendered) + if d.ArtifactType != "" { + writeLine("Artifact Type:", d.ArtifactType) + } + if d.ScanType != "" { + writeLine("Scan Type:", d.ScanType) + } + if d.FileName != "" { + writeLine("File:", d.FileName) + } + if d.FileBytes > 0 { + writeLine("File Size:", formatBytes(d.FileBytes)) + } + if d.StartedAt != "" { + writeLine("Started:", d.StartedAt) + } + if d.UpdatedAt != "" { + writeLine("Updated:", d.UpdatedAt) + } + if d.CompletedAt != nil && *d.CompletedAt != "" { + writeLine("Completed:", *d.CompletedAt) + } + if d.ExpirationTime != "" { + writeLine("Expires:", d.ExpirationTime) + } + if d.LastError != nil && *d.LastError != "" { + writeLine("Last Error:", styles.CriticalBadge.Render(" ERROR ")+" "+*d.LastError) + } + + _, _ = fmt.Fprintln(w) + _, _ = fmt.Fprintln(w, styles.MutedText.Render(scanStatusHint(statusVal))) + if historyEntry != nil { + _, _ = fmt.Fprintln(w, styles.MutedText.Render(fmt.Sprintf("Resolved scan_id from local history (%s).", historyEntry.Artifact))) + } +} + +// scanStatusHint returns a one-line, user-facing description of the current +// state. It covers every ArtifactScanStatus enum value defined by the API +// so the caller always sees something useful (never an empty hint). +func scanStatusHint(status string) string { + switch status { + case "PENDING_UPLOAD": + return "Waiting for the artifact upload to reach S3." + case "UPLOADED": + return "Upload complete; the API is preparing to dispatch the scan." + case "INITIATED": + return "Scan queued; analysis has not started yet." + case "IN_PROGRESS": + return "Analysis is running. Re-run this command to refresh." + case "COMPLETED": + return "Scan finished — retrieve findings with the scan command's output." + case "FAILED": + return "Scan failed. See Last Error above for details." + case "STOPPED": + return "Scan was stopped before it could complete." + default: + return "Unknown scan status." + } +} + +// formatBytes renders a byte count as a human-friendly size (KiB/MiB/...). +// Duplicated from internal/api rather than exported to keep the api +// package's surface small — this is a leaf-level presentation helper. +func formatBytes(n int64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d B", n) + } + div, exp := int64(unit), 0 + for n/div >= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp]) +} + +// ensure the context.Context and time imports don't get pruned when the +// linter checks a stripped-down build. (Cobra pulls both indirectly, but +// keeping the reference explicit here documents that the RunE relies on +// cmd.Context()'s cancellation.) +var _ = context.TODO +var _ = time.Second + +func init() { + scanStatusCmd.Flags().StringVar(&statusFormat, "format", "human", + "Output format: human, json (default: human)") + _ = scanStatusCmd.RegisterFlagCompletionFunc("format", fixedCompletions(statusFormats, map[string]string{ + "human": "Human-readable status block", + "json": "Machine-readable JSON envelope", + })) + scanCmd.AddCommand(scanStatusCmd) +} diff --git a/internal/cmd/scan_status_test.go b/internal/cmd/scan_status_test.go new file mode 100644 index 0000000..1ded03c --- /dev/null +++ b/internal/cmd/scan_status_test.go @@ -0,0 +1,364 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "os" + "strings" + "testing" + + "github.com/ArmisSecurity/armis-cli/internal/model" + "github.com/ArmisSecurity/armis-cli/internal/scan/history" + "github.com/ArmisSecurity/armis-cli/internal/testutil" +) + +// setupStatusEnv fixes the global CLI state to a known baseline for each +// subtest. The scan_status command reuses `getAuthProvider()`, which reads +// the same package-level `token` / `tenantID` / `clientID` variables that +// the sibling scan tests already use — hence the shared shape. +func setupStatusEnv(t *testing.T, serverURL string) func() { + t.Helper() + + origToken := token + origTenant := tenantID + origClientID := clientID + origClientSecret := clientSecret + origColor := colorFlag + origTheme := themeFlag + origNoUpdate := noUpdateCheck + origStatusFormat := statusFormat + + // Route auth away from the real environment. + t.Setenv("ARMIS_CLIENT_ID", "") + t.Setenv("ARMIS_CLIENT_SECRET", "") + t.Setenv("ARMIS_API_URL", serverURL) + // Isolate the scan-history store to a per-test dir so tests never touch + // the developer's ~/.armis file. + t.Setenv("ARMIS_HISTORY_DIR", t.TempDir()) + + token = testToken + tenantID = testTenantID + clientID = "" + clientSecret = "" + colorFlag = testColorNever + themeFlag = themeAuto + noUpdateCheck = true + statusFormat = testFormatHuman + + return func() { + token = origToken + tenantID = origTenant + clientID = origClientID + clientSecret = origClientSecret + colorFlag = origColor + themeFlag = origTheme + noUpdateCheck = origNoUpdate + statusFormat = origStatusFormat + _ = os.Unsetenv("ARMIS_API_URL") + } +} + +// runStatus is a tiny shim around runScanStatus that captures stdout for +// assertion. It calls the RunE directly (rather than executing the full +// cobra tree) so we can inject args and verify the returned error without +// spawning a subprocess. +func runStatus(t *testing.T, args []string) (string, error) { + t.Helper() + var buf bytes.Buffer + scanStatusCmd.SetOut(&buf) + scanStatusCmd.SetErr(&buf) + scanStatusCmd.SetContext(context.Background()) + err := runScanStatus(scanStatusCmd, args) + return buf.String(), err +} + +func statusServer(t *testing.T, status string, extra func(d *model.IngestStatusData)) string { + t.Helper() + handler := func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "/api/v1/ingest/status") { + t.Errorf("Unexpected path: %s", r.URL.Path) + } + if r.Method != http.MethodGet { + t.Errorf("Expected GET, got %s", r.Method) + } + if q := r.URL.Query().Get("tenant_id"); q == "" { + t.Errorf("tenant_id query param missing") + } + if q := r.URL.Query().Get("scan_id"); q == "" { + t.Errorf("scan_id query param missing") + } + data := model.IngestStatusData{ + ScanID: r.URL.Query().Get("scan_id"), + TenantID: r.URL.Query().Get("tenant_id"), + ScanStatus: status, + ArtifactType: "repo", + ScanType: "full", + FileName: "example.tar.gz", + FileBytes: 2048, + StartedAt: "2026-07-27T10:00:00Z", + UpdatedAt: "2026-07-27T10:05:00Z", + ExpirationTime: "2026-08-27T10:00:00Z", + } + if extra != nil { + extra(&data) + } + testutil.JSONResponse(t, w, http.StatusOK, model.IngestStatusResponse{ + Data: []model.IngestStatusData{data}, + }) + } + server := testutil.NewTestServer(t, handler) + return server.URL +} + +// TestScanStatus_AllStatuses walks every ArtifactScanStatus enum value the +// API documents. For each one we verify the command exits without error, +// prints the expected status, and includes the state-specific hint line +// (the guidance sentence that lives under scanStatusHint). +func TestScanStatus_AllStatuses(t *testing.T) { + cases := []struct { + status string + wantHint string + }{ + {"PENDING_UPLOAD", "Waiting for the artifact upload"}, + {"UPLOADED", "Upload complete"}, + {"INITIATED", "Scan queued"}, + {"IN_PROGRESS", "Analysis is running"}, + {"COMPLETED", "Scan finished"}, + {statusValFailed, "Scan failed"}, + {"STOPPED", "Scan was stopped"}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.status, func(t *testing.T) { + url := statusServer(t, tc.status, func(d *model.IngestStatusData) { + if tc.status == statusValFailed { + e := "worker crashed" + d.LastError = &e + } + }) + cleanup := setupStatusEnv(t, url) + defer cleanup() + + out, err := runStatus(t, []string{"scan-abc"}) + if err != nil { + t.Fatalf("runStatus %s: %v", tc.status, err) + } + if !strings.Contains(out, tc.status) { + t.Errorf("output does not include status %q:\n%s", tc.status, out) + } + if !strings.Contains(out, tc.wantHint) { + t.Errorf("output does not include hint %q:\n%s", tc.wantHint, out) + } + if tc.status == statusValFailed && !strings.Contains(out, "worker crashed") { + t.Errorf("FAILED output missing last error line:\n%s", out) + } + }) + } +} + +// TestScanStatus_JSONFormat verifies the JSON output shape. Downstream +// tooling relies on the top-level `scan_id` plus the nested `status.*` +// fields; if we ever break that shape the CI (rather than a customer) +// should be the one to notice. +func TestScanStatus_JSONFormat(t *testing.T) { + url := statusServer(t, "COMPLETED", nil) + cleanup := setupStatusEnv(t, url) + defer cleanup() + + statusFormat = statusFormatJSON + out, err := runStatus(t, []string{"scan-xyz"}) + if err != nil { + t.Fatalf("runStatus json: %v", err) + } + + var payload scanStatusJSON + if err := json.Unmarshal([]byte(out), &payload); err != nil { + t.Fatalf("output is not valid JSON: %v\n%s", err, out) + } + if payload.ScanID != "scan-xyz" { + t.Errorf("payload.ScanID = %q, want scan-xyz", payload.ScanID) + } + if payload.StatusData.ScanStatus != "COMPLETED" { + t.Errorf("payload.StatusData.ScanStatus = %q, want COMPLETED", payload.StatusData.ScanStatus) + } + // When the scan_id came from the CLI arg (not history) the marker + // should be absent. + if payload.FromCache { + t.Errorf("FromCache should be false when scan_id is passed explicitly") + } +} + +// TestScanStatus_FallsBackToHistory covers the "no scan_id argument" path: +// the command should read the most recent scan out of the history store +// and query the API for that ID. +func TestScanStatus_FallsBackToHistory(t *testing.T) { + url := statusServer(t, "IN_PROGRESS", nil) + cleanup := setupStatusEnv(t, url) + defer cleanup() + + // Seed the history store with a matching entry so the command has + // something to fall back on. + store := history.NewStore() + if err := store.Save(history.Entry{ + BaseURL: url, + TenantID: testTenantID, + ScanID: "history-scan-42", + ArtifactType: "repo", + Artifact: "test-repo", + }); err != nil { + t.Fatalf("seed history: %v", err) + } + + out, err := runStatus(t, []string{}) + if err != nil { + t.Fatalf("runStatus fallback: %v", err) + } + if !strings.Contains(out, "history-scan-42") { + t.Errorf("expected fallback scan_id in output, got:\n%s", out) + } + if !strings.Contains(out, "Resolved scan_id from local history") { + t.Errorf("expected history-fallback hint in output, got:\n%s", out) + } +} + +// TestScanStatus_NoArgNoHistory verifies the failure mode when the user +// asks for the "last scan" and none exists. The error text must include +// both the tenant and the base URL so the user knows what the CLI was +// looking for. +func TestScanStatus_NoArgNoHistory(t *testing.T) { + // No handler needed — the command must fail before making a network + // call. Point at a dummy https URL so NewClient still accepts the + // base URL when it runs. + cleanup := setupStatusEnv(t, "https://example.invalid") + defer cleanup() + + _, err := runStatus(t, []string{}) + if err == nil { + t.Fatal("expected error when no scan_id is provided and history is empty") + } + msg := err.Error() + if !strings.Contains(msg, "no scan_id provided") { + t.Errorf("error text should mention missing scan_id: %s", msg) + } + if !strings.Contains(msg, testTenantID) { + t.Errorf("error text should mention tenant %q: %s", testTenantID, msg) + } +} + +// TestScanStatus_APIErrors verifies the 404 / 403 / 422 translation. Users +// commonly hit 404 when they paste the wrong scan_id — a plain "status +// 404: ..." bubbling up is far worse than a targeted hint. +func TestScanStatus_APIErrors(t *testing.T) { + cases := []struct { + name string + httpCode int + want string + }{ + {"not_found", http.StatusNotFound, "scan bad-id not found"}, + {"forbidden", http.StatusForbidden, "access denied for scan"}, + {"validation", http.StatusUnprocessableEntity, "invalid scan_id"}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + handler := func(w http.ResponseWriter, _ *http.Request) { + testutil.ErrorResponse(w, tc.httpCode, "boom") + } + server := testutil.NewTestServer(t, handler) + cleanup := setupStatusEnv(t, server.URL) + defer cleanup() + + _, err := runStatus(t, []string{"bad-id"}) + if err == nil { + t.Fatal("expected error from API failure") + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("error = %q, want to contain %q", err.Error(), tc.want) + } + }) + } +} + +// TestScanStatus_EmptyData exercises the (unusual but documented) case where +// the API returns a 200 with an empty `data` array. Silently dropping the +// user with no output would be worse than a small explicit error. +func TestScanStatus_EmptyData(t *testing.T) { + handler := func(w http.ResponseWriter, _ *http.Request) { + testutil.JSONResponse(t, w, http.StatusOK, model.IngestStatusResponse{Data: []model.IngestStatusData{}}) + } + server := testutil.NewTestServer(t, handler) + cleanup := setupStatusEnv(t, server.URL) + defer cleanup() + + _, err := runStatus(t, []string{"scan-empty"}) + if err == nil { + t.Fatal("expected error when API returns empty data") + } + if !strings.Contains(err.Error(), "no status data returned") { + t.Errorf("error = %q, expected 'no status data returned'", err.Error()) + } +} + +// TestScanStatus_EmptyScanIDArg verifies the explicit-empty-string guard. +// Someone piping a shell variable can end up passing "" as the arg; we +// should refuse it instead of silently falling through to the history +// lookup. +func TestScanStatus_EmptyScanIDArg(t *testing.T) { + cleanup := setupStatusEnv(t, "https://example.invalid") + defer cleanup() + + _, err := runStatus(t, []string{""}) + if err == nil { + t.Fatal("expected error for empty scan_id argument") + } + if !strings.Contains(err.Error(), "scan_id argument is empty") { + t.Errorf("error = %q, expected empty-arg message", err.Error()) + } +} + +// TestScanStatus_InvalidFormat verifies flag validation. The status +// command only supports human/json — SARIF and JUnit are meaningless for +// a single status record and must be rejected up front. +func TestScanStatus_InvalidFormat(t *testing.T) { + cleanup := setupStatusEnv(t, "https://example.invalid") + defer cleanup() + + statusFormat = "sarif" + _, err := runStatus(t, []string{"scan-x"}) + if err == nil { + t.Fatal("expected error for invalid --format value") + } + if !strings.Contains(err.Error(), "invalid --format") { + t.Errorf("error = %q, expected format-validation message", err.Error()) + } +} + +// TestScanStatus_HistoryScopedByEnv makes sure the fallback respects the +// (base_url, tenant_id) scoping — a scan initiated against dev must not be +// returned as the "latest" scan when the CLI is talking to prod. +func TestScanStatus_HistoryScopedByEnv(t *testing.T) { + url := statusServer(t, "COMPLETED", nil) + cleanup := setupStatusEnv(t, url) + defer cleanup() + + store := history.NewStore() + // Seed a scan on a DIFFERENT base URL — the command should ignore it. + if err := store.Save(history.Entry{ + BaseURL: "https://some-other.armis.com", + TenantID: testTenantID, + ScanID: "wrong-env-scan", + ArtifactType: "repo", + }); err != nil { + t.Fatalf("seed history: %v", err) + } + + _, err := runStatus(t, []string{}) + if err == nil { + t.Fatal("expected error since history has no entry for the current base URL") + } + if strings.Contains(err.Error(), "wrong-env-scan") { + t.Errorf("cross-env scan_id leaked into fallback: %v", err) + } +} diff --git a/internal/scan/history/history.go b/internal/scan/history/history.go new file mode 100644 index 0000000..f2065e0 --- /dev/null +++ b/internal/scan/history/history.go @@ -0,0 +1,238 @@ +// Package history persists metadata for scans initiated by the CLI so that +// follow-up commands (e.g. `armis-cli scan status`) can look up the most +// recent scan without requiring the user to remember the scan_id. +// +// The file is a plain JSON array of entries scoped by (base_url, tenant_id). +// It lives alongside the SSO session file under ~/.armis so it inherits the +// same 0700 directory permissions and is stored owner-only (0600) on +// macOS/Linux. Corrupted or oversized files are treated as empty so a stale +// history file never breaks a scan. +package history + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +const ( + // storeDirName is the per-user Armis config directory (~/.armis). It + // deliberately matches the SSO session file's directory so both share + // the same 0700 protection. + storeDirName = ".armis" + // storeFileName is the history file within that directory. + storeFileName = "scan-history.json" + // schemaVersion versions the on-disk JSON so older readers can detect + // (rather than mis-parse) a format bump. + schemaVersion = 1 + // maxFileSize bounds reads to guard against a corrupted or maliciously + // large history file exhausting memory. + maxFileSize = 1 << 20 // 1MB + // MaxEntries caps the number of retained scans. Older entries are + // evicted on Save so the file can never grow without bound. + MaxEntries = 20 +) + +// Entry describes one scan the CLI initiated. +type Entry struct { + SchemaVersion int `json:"schema_version"` + BaseURL string `json:"base_url"` + TenantID string `json:"tenant_id"` + ScanID string `json:"scan_id"` + ArtifactType string `json:"artifact_type"` + Artifact string `json:"artifact,omitempty"` + StartedAt time.Time `json:"started_at"` +} + +// Store persists Entry values to a per-user JSON file. +type Store struct { + // dir overrides the directory containing the history file. Empty means + // ~/.armis. Set by tests via NewStoreWithDir. + dir string +} + +// envDirOverride names the environment variable that lets tests redirect +// the history file away from the real ~/.armis directory. It is +// deliberately undocumented for end users — only the CLI's own test suite +// needs to override the default path. +const envDirOverride = "ARMIS_HISTORY_DIR" + +// NewStore returns a Store backed by ~/.armis/scan-history.json (or the +// directory in $ARMIS_HISTORY_DIR when that env var is set, for tests). +// +// armis:ignore cwe:22 reason:ARMIS_HISTORY_DIR is a process-local env var (same trust boundary as ARMIS_API_URL/ARMIS_API_TOKEN); the directory is combined with a hardcoded filename and only ever stores our own JSON blob +func NewStore() *Store { + return &Store{dir: os.Getenv(envDirOverride)} +} + +// NewStoreWithDir returns a Store rooted at dir (test-only helper). +func NewStoreWithDir(dir string) *Store { + return &Store{dir: dir} +} + +// Save inserts entry, replacing any existing entry with the same +// (base_url, tenant_id, scan_id) triple, then trims the file to the most +// recent MaxEntries scans. StartedAt is stamped to time.Now() when zero. +func (s *Store) Save(entry Entry) error { + if entry.ScanID == "" { + return errors.New("scan_id is required") + } + if entry.TenantID == "" { + return errors.New("tenant_id is required") + } + if entry.BaseURL == "" { + return errors.New("base_url is required") + } + entry.SchemaVersion = schemaVersion + entry.BaseURL = normalizeBaseURL(entry.BaseURL) + if entry.StartedAt.IsZero() { + entry.StartedAt = time.Now().UTC() + } else { + entry.StartedAt = entry.StartedAt.UTC() + } + + entries, _ := s.read() + + kept := entries[:0] + for _, e := range entries { + if e.ScanID == entry.ScanID && + e.TenantID == entry.TenantID && + normalizeBaseURL(e.BaseURL) == entry.BaseURL { + continue + } + kept = append(kept, e) + } + kept = append(kept, entry) + + sort.SliceStable(kept, func(i, j int) bool { + return kept[i].StartedAt.After(kept[j].StartedAt) + }) + if len(kept) > MaxEntries { + kept = kept[:MaxEntries] + } + + return s.write(kept) +} + +// Latest returns the most recent entry matching (baseURL, tenantID), or +// (nil, nil) when no such entry exists. A corrupted history file is treated +// as absent so the caller can prompt for an explicit scan_id. +func (s *Store) Latest(baseURL, tenantID string) (*Entry, error) { + if tenantID == "" || baseURL == "" { + return nil, nil + } + entries, err := s.read() + if err != nil { + return nil, nil //nolint:nilerr // unreadable/corrupted file treated as absent + } + baseURL = normalizeBaseURL(baseURL) + var best *Entry + for i := range entries { + e := entries[i] + if e.TenantID != tenantID { + continue + } + if normalizeBaseURL(e.BaseURL) != baseURL { + continue + } + if best == nil || e.StartedAt.After(best.StartedAt) { + eCopy := e + best = &eCopy + } + } + return best, nil +} + +// Path returns the resolved history file path (for diagnostic messages). +func (s *Store) Path() string { + path, _ := s.filePath() + return path +} + +// read loads and parses the history file. A missing file yields an empty +// slice; an oversized/invalid file yields an error so Save can decide to +// overwrite it rather than appending to garbage. +func (s *Store) read() ([]Entry, error) { + path, err := s.filePath() + if err != nil { + return nil, err + } + // armis:ignore cwe:367 reason:stat-then-read race is benign; worst case reads a stale entry, no security impact + info, statErr := os.Stat(path) + if statErr != nil { + if os.IsNotExist(statErr) { + return nil, nil + } + return nil, statErr + } + if info.Size() > maxFileSize { + return nil, fmt.Errorf("history file %s exceeds %d bytes", path, maxFileSize) + } + // armis:ignore cwe:22 reason:path derived from os.UserHomeDir + hardcoded segments + data, err := os.ReadFile(path) //nolint:gosec // path derived from os.UserHomeDir + hardcoded segments + if err != nil { + return nil, err + } + if len(data) == 0 { + return nil, nil + } + var entries []Entry + if err := json.Unmarshal(data, &entries); err != nil { + return nil, fmt.Errorf("history file is not valid JSON: %w", err) + } + return entries, nil +} + +// write persists entries to the 0600 file, creating ~/.armis (0700) if +// needed. An empty slice deletes the file so the on-disk footprint is +// nothing when the user has no scan history. +func (s *Store) write(entries []Entry) error { + path, err := s.filePath() + if err != nil { + return err + } + if len(entries) == 0 { + if rerr := os.Remove(path); rerr != nil && !os.IsNotExist(rerr) { + return rerr + } + return nil + } + data, err := json.MarshalIndent(entries, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal history: %w", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("failed to create history directory: %w", err) + } + // armis:ignore cwe:22 reason:path derived from os.UserHomeDir + hardcoded segments + if err := os.WriteFile(path, data, 0o600); err != nil { //nolint:gosec // path derived from os.UserHomeDir + hardcoded segments + return fmt.Errorf("failed to write history file: %w", err) + } + return nil +} + +// filePath resolves the history file path: /scan-history.json where +// dir is the test override or ~/.armis. +func (s *Store) filePath() (string, error) { + dir := s.dir + if dir == "" { + // armis:ignore cwe:22 reason:os.UserHomeDir is a trusted OS source; joined with hardcoded path segments + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("cannot determine home directory: %w", err) + } + dir = filepath.Join(home, storeDirName) + } + return filepath.Join(dir, storeFileName), nil +} + +// normalizeBaseURL canonicalizes a base URL so trivial spelling differences +// (trailing slash, case, whitespace) resolve to the same history bucket. +func normalizeBaseURL(u string) string { + return strings.TrimRight(strings.ToLower(strings.TrimSpace(u)), "/") +} diff --git a/internal/scan/history/history_test.go b/internal/scan/history/history_test.go new file mode 100644 index 0000000..fde928c --- /dev/null +++ b/internal/scan/history/history_test.go @@ -0,0 +1,276 @@ +package history + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "testing" + "time" +) + +const ( + envProd = "https://moose.armis.com" + envDev = "https://moose-dev.armis.com" + tenantA = "tenant-a" + tenantB = "tenant-b" + sampleScan = "scan-1" +) + +func newEntry(scanID, tenantID, baseURL string, startedAt time.Time) Entry { + return Entry{ + BaseURL: baseURL, + TenantID: tenantID, + ScanID: scanID, + ArtifactType: "repo", + Artifact: "example", + StartedAt: startedAt, + } +} + +func TestSaveAndLatest(t *testing.T) { + store := NewStoreWithDir(t.TempDir()) + + now := time.Now().UTC().Truncate(time.Second) + if err := store.Save(newEntry(sampleScan, tenantA, envProd, now)); err != nil { + t.Fatalf("Save: %v", err) + } + + got, err := store.Latest(envProd, tenantA) + if err != nil { + t.Fatalf("Latest: %v", err) + } + if got == nil { + t.Fatal("Latest returned nil for saved entry") + } + if got.ScanID != sampleScan || got.TenantID != tenantA { + t.Errorf("Latest = %+v, want scan_id=%s tenant_id=%s", got, sampleScan, tenantA) + } + if got.SchemaVersion != schemaVersion { + t.Errorf("SchemaVersion = %d, want %d", got.SchemaVersion, schemaVersion) + } +} + +func TestSavePersistsFileWithOwnerOnlyPerms(t *testing.T) { + dir := t.TempDir() + store := NewStoreWithDir(dir) + + if err := store.Save(newEntry(sampleScan, tenantA, envProd, time.Now())); err != nil { + t.Fatalf("Save: %v", err) + } + + path := filepath.Join(dir, storeFileName) + info, err := os.Stat(path) + if err != nil { + t.Fatalf("expected history file: %v", err) + } + if runtime.GOOS != "windows" { + if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("history file perm = %o, want 600", perm) + } + } +} + +func TestLatestPicksMostRecentForTenant(t *testing.T) { + store := NewStoreWithDir(t.TempDir()) + + older := time.Now().UTC().Add(-2 * time.Hour) + newer := time.Now().UTC().Add(-1 * time.Hour) + + if err := store.Save(newEntry("scan-old", tenantA, envProd, older)); err != nil { + t.Fatalf("Save old: %v", err) + } + if err := store.Save(newEntry("scan-new", tenantA, envProd, newer)); err != nil { + t.Fatalf("Save new: %v", err) + } + // Also save an unrelated entry for tenant B to prove scoping is honored. + if err := store.Save(newEntry("scan-b", tenantB, envProd, time.Now())); err != nil { + t.Fatalf("Save b: %v", err) + } + + got, err := store.Latest(envProd, tenantA) + if err != nil { + t.Fatalf("Latest: %v", err) + } + if got == nil || got.ScanID != "scan-new" { + t.Errorf("Latest for tenantA = %+v, want scan-new", got) + } +} + +func TestLatestScopedByBaseURL(t *testing.T) { + store := NewStoreWithDir(t.TempDir()) + + if err := store.Save(newEntry("prod-scan", tenantA, envProd, time.Now())); err != nil { + t.Fatalf("Save prod: %v", err) + } + if err := store.Save(newEntry("dev-scan", tenantA, envDev, time.Now())); err != nil { + t.Fatalf("Save dev: %v", err) + } + + prod, err := store.Latest(envProd, tenantA) + if err != nil || prod == nil || prod.ScanID != "prod-scan" { + t.Errorf("Latest(prod) = %+v, err=%v; want prod-scan", prod, err) + } + dev, err := store.Latest(envDev, tenantA) + if err != nil || dev == nil || dev.ScanID != "dev-scan" { + t.Errorf("Latest(dev) = %+v, err=%v; want dev-scan", dev, err) + } +} + +func TestLatestReturnsNilWhenMissing(t *testing.T) { + store := NewStoreWithDir(t.TempDir()) + + got, err := store.Latest(envProd, tenantA) + if err != nil { + t.Fatalf("Latest: %v", err) + } + if got != nil { + t.Errorf("Latest = %+v, want nil", got) + } +} + +func TestSaveTrimsToMaxEntries(t *testing.T) { + store := NewStoreWithDir(t.TempDir()) + + base := time.Now().UTC() + total := MaxEntries + 5 + for i := 0; i < total; i++ { + entry := newEntry( + // Every entry gets a unique scan_id so nothing dedups. + "scan-"+padIndex(i), + tenantA, + envProd, + base.Add(time.Duration(i)*time.Minute), + ) + if err := store.Save(entry); err != nil { + t.Fatalf("Save[%d]: %v", i, err) + } + } + + entries, err := store.read() + if err != nil { + t.Fatalf("read: %v", err) + } + if len(entries) != MaxEntries { + t.Fatalf("kept %d entries, want %d", len(entries), MaxEntries) + } + // Newest first — the oldest 5 should have been evicted. + if entries[0].ScanID != "scan-"+padIndex(total-1) { + t.Errorf("newest entry = %s, want scan-%s", entries[0].ScanID, padIndex(total-1)) + } + oldestKept := "scan-" + padIndex(total-MaxEntries) + if entries[len(entries)-1].ScanID != oldestKept { + t.Errorf("oldest retained = %s, want %s", entries[len(entries)-1].ScanID, oldestKept) + } +} + +func TestSaveDedupsSameTriple(t *testing.T) { + store := NewStoreWithDir(t.TempDir()) + + older := time.Now().UTC().Add(-1 * time.Hour) + newer := time.Now().UTC() + + first := newEntry(sampleScan, tenantA, envProd, older) + first.Artifact = "old-artifact" + if err := store.Save(first); err != nil { + t.Fatalf("Save first: %v", err) + } + second := newEntry(sampleScan, tenantA, envProd, newer) + second.Artifact = "new-artifact" + if err := store.Save(second); err != nil { + t.Fatalf("Save second: %v", err) + } + + entries, err := store.read() + if err != nil { + t.Fatalf("read: %v", err) + } + if len(entries) != 1 { + t.Fatalf("expected dedup to 1 entry, got %d", len(entries)) + } + if entries[0].Artifact != "new-artifact" { + t.Errorf("expected latest write to win, got %q", entries[0].Artifact) + } +} + +func TestSaveRejectsMissingFields(t *testing.T) { + store := NewStoreWithDir(t.TempDir()) + + cases := []struct { + name string + entry Entry + }{ + {"missing scan_id", Entry{BaseURL: envProd, TenantID: tenantA}}, + {"missing tenant_id", Entry{BaseURL: envProd, ScanID: sampleScan}}, + {"missing base_url", Entry{TenantID: tenantA, ScanID: sampleScan}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := store.Save(tc.entry); err == nil { + t.Error("expected error for missing field, got nil") + } + }) + } +} + +func TestLatestIgnoresCorruptFile(t *testing.T) { + dir := t.TempDir() + store := NewStoreWithDir(dir) + // Write garbage in place of a valid history file. + if err := os.WriteFile(filepath.Join(dir, storeFileName), []byte("{not json"), 0o600); err != nil { + t.Fatalf("seed corrupt file: %v", err) + } + + got, err := store.Latest(envProd, tenantA) + if err != nil { + t.Fatalf("Latest returned error for corrupt file: %v", err) + } + if got != nil { + t.Errorf("Latest = %+v, want nil for corrupt file", got) + } +} + +func TestSaveNormalizesBaseURL(t *testing.T) { + store := NewStoreWithDir(t.TempDir()) + + if err := store.Save(newEntry(sampleScan, tenantA, " HTTPS://Moose.Armis.com/ ", time.Now())); err != nil { + t.Fatalf("Save: %v", err) + } + + got, err := store.Latest("https://moose.armis.com", tenantA) + if err != nil { + t.Fatalf("Latest: %v", err) + } + if got == nil { + t.Fatal("Latest returned nil after normalization") + } +} + +func TestSaveEncodesJSONArray(t *testing.T) { + dir := t.TempDir() + store := NewStoreWithDir(dir) + + if err := store.Save(newEntry(sampleScan, tenantA, envProd, time.Now())); err != nil { + t.Fatalf("Save: %v", err) + } + + raw, err := os.ReadFile(filepath.Join(dir, storeFileName)) //nolint:gosec // test file under t.TempDir + if err != nil { + t.Fatalf("read history: %v", err) + } + var out []Entry + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("history file must be a JSON array of entries: %v (%s)", err, raw) + } +} + +// padIndex zero-pads an integer to a fixed 4-digit width so lexicographic +// scan_id ordering matches numeric ordering in the assertion above. +func padIndex(i int) string { + buf := []byte("0000") + for k := len(buf) - 1; k >= 0 && i > 0; k-- { + buf[k] = byte('0' + i%10) + i /= 10 + } + return string(buf) +} diff --git a/internal/scan/image/image.go b/internal/scan/image/image.go index 9528972..d4a2002 100644 --- a/internal/scan/image/image.go +++ b/internal/scan/image/image.go @@ -179,6 +179,7 @@ func (s *Scanner) ScanTarball(ctx context.Context, tarballPath string) (*model.S if err != nil { return nil, fmt.Errorf("failed to upload image: %w", err) } + scan.RecordScanStarted(s.client.BaseURL(), s.tenantID, scanID, "image", filepath.Base(tarballPath)) uploadSpinner.Stop() styles := output.GetStyles() diff --git a/internal/scan/record.go b/internal/scan/record.go new file mode 100644 index 0000000..5c2f663 --- /dev/null +++ b/internal/scan/record.go @@ -0,0 +1,31 @@ +package scan + +import ( + "github.com/ArmisSecurity/armis-cli/internal/scan/history" +) + +// RecordScanStarted persists the scan_id the API just handed us so a later +// `armis-cli scan status` (without an argument) can look it up. This is +// best-effort: a failure to write the history file must NEVER break a scan +// that has already been dispatched, so any error is swallowed here. +// +// The scan-history store is per-user, scoped by (base_url, tenant_id), and +// capped at history.MaxEntries entries so the on-disk footprint stays small. +// +// Fields: +// - baseURL: the API base URL the client is talking to. Different Armis +// environments (prod, dev, a local stack) get separate history buckets. +// - tenantID / scanID: identify the scan on the server side. +// - artifactType: one of "repo", "image", "sbom" — carried purely so the +// `scan status` output can label the run. +// - artifact: a short human label (repo path, image name, sbom filename). +// Optional; empty is fine. +func RecordScanStarted(baseURL, tenantID, scanID, artifactType, artifact string) { + _ = history.NewStore().Save(history.Entry{ + BaseURL: baseURL, + TenantID: tenantID, + ScanID: scanID, + ArtifactType: artifactType, + Artifact: artifact, + }) +} diff --git a/internal/scan/repo/repo.go b/internal/scan/repo/repo.go index cf664c1..23820d4 100644 --- a/internal/scan/repo/repo.go +++ b/internal/scan/repo/repo.go @@ -220,6 +220,7 @@ func (s *Scanner) Scan(ctx context.Context, path string) (*model.ScanResult, err if err != nil { return nil, fmt.Errorf("failed to upload repository: %w", err) } + scan.RecordScanStarted(s.client.BaseURL(), s.tenantID, scanID, "repo", filepath.Base(absPath)) // Close the tarball explicitly now that StartIngest has finished reading it. // A failure here means buffered writes never reached disk; the upload diff --git a/internal/scan/sbom/sbom.go b/internal/scan/sbom/sbom.go index 89b6631..e25ab8a 100644 --- a/internal/scan/sbom/sbom.go +++ b/internal/scan/sbom/sbom.go @@ -162,6 +162,7 @@ func (s *Scanner) Scan(ctx context.Context, path string) (*model.ScanResult, err if err != nil { return nil, fmt.Errorf("failed to upload SBOM: %w", err) } + scan.RecordScanStarted(s.client.BaseURL(), s.tenantID, scanID, "sbom", filename) spinner.Stop() styles := output.GetStyles() From a9d951d1e8a52ed4b9171e2a9ad0fe16d93991f5 Mon Sep 17 00:00:00 2001 From: Rushabh Thakkar Date: Mon, 27 Jul 2026 14:41:47 +0530 Subject: [PATCH 2/2] fix(cmd): dedup human/json format literals across scan commands CI golangci-lint (fresh cache) surfaced the goconst rule for every "human" and "json" literal in root.go, scan.go, and scan_status.go once the shared statusFormatHuman/statusFormatJSON constants exist. Promote those constants to scan.go (next to validFormats) and reuse them in all three files. --- internal/cmd/root.go | 8 ++++---- internal/cmd/scan.go | 10 +++++++++- internal/cmd/scan_status.go | 7 ++----- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 06ee635..77e09b3 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -283,10 +283,10 @@ func fixedCompletions(values []string, descriptions map[string]string) cobra.Com // sites to keep them from drifting apart. func formatCompletions() cobra.CompletionFunc { return fixedCompletions(validFormats, map[string]string{ - "human": "Human-readable terminal output", - "json": "Machine-readable JSON", - "sarif": "SARIF for code-scanning tools", - "junit": "JUnit XML for CI test reports", + statusFormatHuman: "Human-readable terminal output", + statusFormatJSON: "Machine-readable JSON", + "sarif": "SARIF for code-scanning tools", + "junit": "JUnit XML for CI test reports", }) } diff --git a/internal/cmd/scan.go b/internal/cmd/scan.go index 9a7d114..a329e6f 100644 --- a/internal/cmd/scan.go +++ b/internal/cmd/scan.go @@ -25,8 +25,16 @@ var ( outputFile string ) +// Shared constants for the --format value across scan subcommands. `scan +// status` reuses these to keep goconst happy across cmd/ and to make the +// human/json spelling change in exactly one place if it ever needs to. +const ( + statusFormatHuman = "human" + statusFormatJSON = "json" +) + // validFormats contains the valid output format strings. -var validFormats = []string{"human", "json", "sarif", "junit"} +var validFormats = []string{statusFormatHuman, statusFormatJSON, "sarif", "junit"} // validGroupBy contains the valid group-by options. var validGroupBy = []string{"none", "cwe", "severity", "file"} diff --git a/internal/cmd/scan_status.go b/internal/cmd/scan_status.go index d959fc6..c69433d 100644 --- a/internal/cmd/scan_status.go +++ b/internal/cmd/scan_status.go @@ -26,9 +26,6 @@ var statusFormat string var statusFormats = []string{statusFormatHuman, statusFormatJSON} const ( - statusFormatHuman = "human" - statusFormatJSON = "json" - // Status enum values worth branching on for terminal styling. statusValCompleted = "COMPLETED" statusValFailed = "FAILED" @@ -306,8 +303,8 @@ func init() { scanStatusCmd.Flags().StringVar(&statusFormat, "format", "human", "Output format: human, json (default: human)") _ = scanStatusCmd.RegisterFlagCompletionFunc("format", fixedCompletions(statusFormats, map[string]string{ - "human": "Human-readable status block", - "json": "Machine-readable JSON envelope", + statusFormatHuman: "Human-readable status block", + statusFormatJSON: "Machine-readable JSON envelope", })) scanCmd.AddCommand(scanStatusCmd) }