diff --git a/docs/json-schemas.md b/docs/json-schemas.md index f602fdc..4504e6e 100644 --- a/docs/json-schemas.md +++ b/docs/json-schemas.md @@ -151,6 +151,166 @@ Enum values: - `type`: `BUG | VULNERABILITY | CODE_SMELL` - `status`: `OPEN | CONFIRMED | REOPENED | RESOLVED | CLOSED` +## `krci sca list` + +```json +{ + "schemaVersion": "1", + "data": { + "items": [ + { + "uuid": "550e8400-e29b-41d4-a716-446655440000", + "name": "payments-api", + "version": "main", + "classifier": "APPLICATION", + "active": true, + "isLatest": true, + "lastBomImport": 1713456000000, + "lastBomImportFormat": "CycloneDX 1.4", + "riskScore": 12.5, + "metrics": { + "critical": 1, + "high": 3, + "medium": 8, + "low": 2, + "unassigned": 0, + "vulnerabilities": 14 + } + } + ], + "totalCount": 42 + } +} +``` + +`lastBomImport` is a Unix milliseconds timestamp. `metrics` is optional — the upstream +payload omits it for projects that have never had a BOM uploaded; downstream consumers +should guard on `.data.items[].metrics != null` before indexing counts. + +## `krci sca get ` + +Two variants: `status: "OK"` when the codebase has a Dep-Track project; `status: "NONE"` +when the Portal's lookup returned no project for the resolved `(name, branch)` pair. + +```json +{ + "schemaVersion": "1", + "data": { + "status": "OK", + "project": { + "uuid": "550e8400-e29b-41d4-a716-446655440000", + "name": "payments-api", + "version": "main", + "classifier": "APPLICATION", + "active": true, + "isLatest": true, + "lastBomImport": 1713456000000, + "lastBomImportFormat": "CycloneDX 1.4", + "lastVulnerabilityAnalysis": 1713459600000, + "riskScore": 12.5 + }, + "metrics": { + "critical": 1, + "high": 3, + "medium": 8, + "low": 2, + "unassigned": 0, + "vulnerabilities": 14, + "components": 120, + "vulnerableComponents": 12 + } + } +} +``` + +```json +{ "schemaVersion": "1", "data": { "status": "NONE" } } +``` + +When `status` is `"NONE"` the `project` and `metrics` fields are absent. CLI exit is `0` +in both cases. + +## `krci sca components ` + +```json +{ + "schemaVersion": "1", + "data": { + "status": "OK", + "items": [ + { + "uuid": "5c0e...", + "name": "log4j-core", + "version": "2.11.2", + "latestVersion": "2.24.0", + "outdated": true, + "group": "org.apache.logging.log4j", + "license": "Apache-2.0", + "isInternal": false, + "riskScore": 9.8, + "metrics": { + "critical": 1, + "high": 0, + "medium": 0, + "low": 0, + "unassigned": 0, + "vulnerabilities": 1 + } + } + ], + "totalCount": 120 + } +} +``` + +`status` is `"OK"` for a bound codebase and `"NONE"` for an unbound one (with +`items: [], totalCount: 0`). `outdated` is a server-side flag from Dep-Track — +no client-side semver comparison is performed. When `--severity=` is passed, +the CLI additionally filters client-side to rows whose metrics contain at least +one finding of severity `>= min` (inclusive). + +## `krci sca findings ` + +```json +{ + "schemaVersion": "1", + "data": { + "status": "OK", + "items": [ + { + "component": { + "uuid": "c1", + "name": "log4j-core", + "version": "2.11.2" + }, + "vulnerability": { + "vulnId": "CVE-2021-44228", + "source": "NVD", + "severity": "CRITICAL", + "cvssV3BaseScore": 10.0 + }, + "analysis": { "state": "NOT_SET", "isSuppressed": false }, + "attribution": { + "analyzerIdentity": "OSSINDEX_ANALYZER", + "attributedOn": 1713456000000 + } + } + ], + "truncated": false + } +} +``` + +Results are sorted by `vulnerability.severity` descending (CRITICAL first), then +`component.name` ascending, then `vulnerability.vulnId` ascending. Default +excludes suppressed findings; `--include-suppressed` opts in. + +The Portal caps the response at 1000 rows per request. When the upstream returned +more, the CLI sets `truncated=true` and prints a footer hint in table output; +scripts should branch on `.data.truncated` rather than counting rows. + +Severity enum: `CRITICAL | HIGH | MEDIUM | LOW | INFO | UNASSIGNED`. + ## Error envelope ```json diff --git a/docs/sca.md b/docs/sca.md new file mode 100644 index 0000000..93781ec --- /dev/null +++ b/docs/sca.md @@ -0,0 +1,195 @@ +# `krci sca` — Software Composition Analysis from the terminal + +Inspect the Dependency-Track (DT) state surfaced by the KubeRocketCI Portal — +projects, dependencies, and vulnerability findings — without leaving the +terminal. The CLI reuses the Dep-Track binding already configured on the +Portal (`DEPENDENCY_TRACK_URL` / `DEPENDENCY_TRACK_API_KEY`), so no extra +credentials are required. + +## Subcommands + +| Command | Purpose | +|----------------------------------|---------------------------------------------------------------------------| +| `sca list` | List SCA projects known to Dependency-Track | +| `sca get ` | Project overview: risk score, severity counts, last BOM import | +| `sca components ` | Dependencies with outdated / direct / severity filters | +| `sca findings ` | Flat vulnerability listing (CVE-level) with severity filter + truncation | + +All commands accept `-o, --output` with `table` (default) or `json`. + +### Codebase + branch addressing + +Every per-codebase verb takes a positional `` and an optional +`--branch`. When `--branch` is omitted, the Portal reads +`Codebase.spec.defaultBranch` from Kubernetes and uses that value as the +Dep-Track project "version". You can also supply an explicit branch name or +release tag. + +### Severity filter (inclusive) + +`--severity` accepts case-insensitive values `critical | high | medium | low | info`. +The filter is **inclusive**: `--severity=high` returns rows at HIGH or above; +`--severity=medium` returns CRITICAL + HIGH + MEDIUM. `INFO` implicitly +includes `UNASSIGNED`. + +## `sca list` + +```bash +krci sca list +``` + +``` +NAME VERSION CLASSIFIER ACTIVE LAST_BOM RISK VULNS (C/H/M/L) +payments-api main APPLICATION yes 2h ago 12.5 1/3/8/2 +loyalty-api develop APPLICATION yes 6h ago 0.0 0/0/0/0 + +2 projects, page 1 of 1 (page-size 25) +``` + +Flags: `--search`, `--page`, `--page-size` (max 500), `--include-inactive`, +`--include-children`. Defaults exclude inactive projects and child versions +to match the Portal UI. + +Pick dirty projects from a script: + +```bash +krci sca list -o json | jq -r '.data.items[] | select(.metrics.critical>0) | .name' +``` + +## `sca get` + +```bash +krci sca get payments-api +``` + +``` +payments-api @ main + +Codebase payments-api +Branch main +Classifier APPLICATION +Active yes +Is latest yes +Risk score 12.5 +Last BOM 2026-04-18 09:12 UTC (CycloneDX 1.4), 2h ago +Last vuln scan 1h ago + +Vulnerabilities: + Critical 1 + High 3 + Medium 8 + Low 2 + Info/Unassigned 0 + Total 14 + +Components: + Total 120 + Vulnerable 12 +``` + +Target an explicit branch or pull a single metric: + +```bash +krci sca get payments-api --branch=release/1.0 +krci sca get payments-api -o json | jq -r '.data.project.riskScore' +``` + +When the codebase has no Dep-Track binding: + +```bash +$ krci sca get infra-gitops +status: NONE — no SCA scanner bound for infra-gitops @ main +``` + +`status=NONE` always exits `0` — scripting consumers can branch on +`.data.status`. + +## `sca components` + +```bash +krci sca components payments-api --only-outdated +``` + +``` +COMPONENT CURRENT LATEST OUTDATED LICENSE RISK VULNS (C/H/M/L) +log4j-core 2.11.2 2.24.0 yes Apache-2.0 9.8 1/0/0/0 +commons-text 1.9 1.13.1 yes Apache-2.0 5.5 0/1/0/0 + +2 components, page 1 of 1 (page-size 50) +``` + +Combined filters are **AND** — server-side `--only-outdated` / `--only-direct` +are forwarded to Dep-Track, and `--severity` narrows client-side afterwards: + +```bash +# Outdated direct dependencies with at least one HIGH or CRITICAL finding +krci sca components payments-api --only-direct --only-outdated --severity=high + +# Explicit branch +krci sca components payments-api --branch=release/1.0 +``` + +## `sca findings` + +```bash +krci sca findings payments-api --severity=high +``` + +``` +COMPONENT VERSION VULN_ID SRC SEVERITY CVSS ANALYSIS SUPPRESSED +log4j-core 2.11.2 CVE-2021-44228 NVD CRITICAL 10.0 (v3) NOT_SET no +commons-text 1.9 CVE-2022-42889 NVD CRITICAL 9.8 (v3) NOT_SET no +``` + +Sort: severity desc → component.name asc → vulnerability.vulnId asc. +Default excludes suppressed findings (`--include-suppressed` to opt in). + +Filter by upstream vulnerability source (e.g. `NVD`, `GITHUB`, `OSV`): + +```bash +krci sca findings payments-api --source=NVD +``` + +Very large projects are capped server-side at 1000 rows with a footer hint: + +``` +(findings truncated to 1000 rows — narrow the query via --severity or --source) +``` + +Script-friendly CVE extraction: + +```bash +krci sca findings payments-api -o json | jq -r '.data.items[].vulnerability.vulnId' +``` + +## Exit codes + +| Exit | Meaning | +|------|---------------------------------------------------------------------------| +| `0` | Command succeeded (including `status=NONE` payloads) | +| `1` | Any failure — validation, auth, not-found, upstream unavailable, bad flag | + +## JSON output + +Every command emits a stable `{ "schemaVersion": "1", "data": { … } }` +envelope under `-o json`. See `docs/json-schemas.md` for the exact payload +for each verb. + +## Typical workflows + +```bash +# Find the dirtiest projects +krci sca list -o json \ + | jq -r '.data.items | sort_by(-(.riskScore // 0)) | .[0:5][] | "\(.name)@\(.version) risk=\(.riskScore // 0)"' + +# Zoom into one +krci sca get payments-api +krci sca components payments-api --only-outdated +krci sca findings payments-api --severity=high + +# CI gate — fail when CRITICALs exist on main +if [ "$(krci sca findings payments-api --severity=critical -o json \ + | jq '.data.items | length')" -gt 0 ]; then + echo "new criticals"; exit 1 +fi +``` diff --git a/e2e/sca/test-cases.md b/e2e/sca/test-cases.md new file mode 100644 index 0000000..24f6deb --- /dev/null +++ b/e2e/sca/test-cases.md @@ -0,0 +1,123 @@ +# `krci sca` — e2e test cases + +Covers the `sca` command group and its four subcommands `list`, `get`, +`components`, `findings`. Source: `pkg/cmd/sca/` and `docs/sca.md`. + +Every row is a self-contained contract a Haiku agent can execute. See +`../runner.md` for the agent brief and the **expect grammar** reference. + +## Placeholders resolved per run + +| Placeholder | Meaning | Example | +|----------------------|---------------------------------------------------------------------------|---------------------| +| `{{CODEBASE_OK}}` | A codebase that has a Dep-Track project bound to its default branch. | `payments-api` | +| `{{CODEBASE_NONE}}` | A codebase that exists but has no Dep-Track scanner binding. | `infra-gitops` | +| `{{CODEBASE_MISSING}}` | A codebase name that does not exist in the cluster. | `does-not-exist` | +| `{{BRANCH}}` | A branch/version known to Dep-Track for `{{CODEBASE_OK}}`. | `main` | +| `{{RELEASE_BRANCH}}` | A release tag/branch of `{{CODEBASE_OK}}` with at least one finding. | `release/1.0` | + +The orchestrator fills these; the table never hard-codes them. + +--- + +## 1. Help & discovery (env: `offline`) + +Fast, idempotent, no portal — these are the first line of defence. + +| ID | Command | Env | Setup | Expect | +|------------|------------------------------------------|---------|-------|-------------------------------------------------------------------------------------------------------------------------------| +| SCA-H-01 | `krci sca --help` | offline | — | `exit=0; stdout~/Inspect Software Composition Analysis/; stdout~/^\s+list\s/; stdout~/^\s+get\s/; stdout~/^\s+components\s/; stdout~/^\s+findings\s/` | +| SCA-H-02 | `krci sca list --help` | offline | — | `exit=0; stdout~/--search string/; stdout~/--page int/; stdout~/--page-size int/; stdout~/--include-inactive/; stdout~/--include-children/; stdout~/-o, --output string/` | +| SCA-H-03 | `krci sca get --help` | offline | — | `exit=0; stdout~/Dep-Track project 'version' field/; stdout~/--branch string/; stdout!~/--pr/` | +| SCA-H-04 | `krci sca components --help` | offline | — | `exit=0; stdout~/--only-outdated/; stdout~/--only-direct/; stdout~/--severity/; stdout~/inclusive/; stdout!~/--pr/` | +| SCA-H-05 | `krci sca findings --help` | offline | — | `exit=0; stdout~/--include-suppressed/; stdout~/--source string/; stdout~/--severity/; stdout!~/--pr/` | +| SCA-H-06 | `krci sca` | offline | — | `exit=0; stdout~/^Available Commands:$/; stdout~/^\s+list\s/; stdout~/^\s+get\s/; stdout~/^\s+components\s/; stdout~/^\s+findings\s/` | + +## 2. Argument validation (env: `offline`) + +Wrong shape of invocation must fail fast with a helpful message and a +non-zero exit. + +| ID | Command | Env | Setup | Expect | +|------------|----------------------------------------------------------------------|---------|-------|--------------------------------------------------------------------------------------------------------------------| +| SCA-V-01 | `krci sca get` | offline | — | `exit=1; stderr~/requires a KubeRocketCI codebase name/` | +| SCA-V-02 | `krci sca components` | offline | — | `exit=1; stderr~/requires a KubeRocketCI codebase name/` | +| SCA-V-03 | `krci sca findings` | offline | — | `exit=1; stderr~/requires a KubeRocketCI codebase name/` | +| SCA-V-04 | `krci sca get svc --pr 42` | offline | — | `exit=1; stderr~/unknown flag: --pr/` | +| SCA-V-05 | `krci sca components svc --pr 42` | offline | — | `exit=1; stderr~/unknown flag: --pr/` | +| SCA-V-06 | `krci sca findings svc --pr 42` | offline | — | `exit=1; stderr~/unknown flag: --pr/` | +| SCA-V-07 | `krci sca list -o yaml` | offline | — | `exit=1; stderr~/unknown output format/` | +| SCA-V-08 | `krci sca list --page 0` | offline | — | `exit=1; stderr~/--page must be >= 1/` | +| SCA-V-09 | `krci sca list --page-size 1000` | offline | — | `exit=1; stderr~/--page-size must be between 1 and 500/` | +| SCA-V-10 | `krci sca components svc --severity=garbage` | offline | — | `exit=1; stderr~/invalid --severity/; stderr~/CRITICAL/; stderr~/HIGH/; stderr~/UNASSIGNED/` | +| SCA-V-11 | `krci sca findings svc --severity=garbage` | offline | — | `exit=1; stderr~/invalid --severity/` | +| SCA-V-12 | `krci sca get Not_A_DNS_Label` | offline | — | `exit=1; stderr~/DNS-1123/` | +| SCA-V-13 | `krci sca --unknown-flag` | offline | — | `exit=1; stderr~/unknown flag: --unknown-flag/` | + +## 3. Happy paths (env: `portal`) + +Requires `krci auth status` → Authenticated. Exercises the golden path for +each verb. + +| ID | Command | Env | Setup | Expect | +|------------|------------------------------------------------------------------------|--------|-------|-------------------------------------------------------------------------------------------------------------------------| +| SCA-P-01 | `krci sca list` | portal | — | `exit=0; stdout~/NAME/; stdout~/VERSION/; stdout~/CLASSIFIER/; stdout~/LAST_BOM/; stdout~/VULNS \(C\/H\/M\/L\)/` | +| SCA-P-02 | `krci sca list -o json` | portal | — | `exit=0; stdout-json~/\.data\.items is array/; stdout-json~/\.data\.totalCount is number/; stdout-json~/\.schemaVersion == "1"/` | +| SCA-P-03 | `krci sca list --page 1 --page-size 10 --search={{CODEBASE_OK}}` | portal | — | `exit=0; stdout~/{{CODEBASE_OK}}/` | +| SCA-P-04 | `krci sca list --include-inactive --include-children -o json` | portal | — | `exit=0; stdout-json~/\.data\.items is array/` | +| SCA-P-05 | `krci sca get {{CODEBASE_OK}}` | portal | — | `exit=0; stdout~/{{CODEBASE_OK}} @ /; stdout~/Classifier/; stdout~/Risk score/; stdout~/Vulnerabilities/` | +| SCA-P-06 | `krci sca get {{CODEBASE_OK}} -o json` | portal | — | `exit=0; stdout-json~/\.data\.status == "OK"/; stdout-json~/\.data\.project\.name == "{{CODEBASE_OK}}"/` | +| SCA-P-07 | `krci sca get {{CODEBASE_OK}} --branch={{RELEASE_BRANCH}} -o json` | portal | — | `exit=0; stdout-json~/\.data\.status in ["OK","NONE"]/` | +| SCA-P-08 | `krci sca components {{CODEBASE_OK}} --page-size 10` | portal | — | `exit=0; stdout~/COMPONENT/; stdout~/CURRENT/; stdout~/LATEST/; stdout~/OUTDATED/; stdout~/VULNS \(C\/H\/M\/L\)/` | +| SCA-P-09 | `krci sca components {{CODEBASE_OK}} --only-outdated -o json` | portal | — | `exit=0; stdout-json~/\.data\.status == "OK"/; stdout-json~/\.data\.items is array/` | +| SCA-P-10 | `krci sca findings {{CODEBASE_OK}} -o json` | portal | — | `exit=0; stdout-json~/\.data\.status == "OK"/; stdout-json~/\.data\.items is array/; stdout-json~/\.data\.truncated is boolean/` | + +## 4. NONE state (env: `portal`) + +Unbound codebases produce a `status=NONE` payload with exit `0`. + +| ID | Command | Env | Setup | Expect | +|------------|------------------------------------------------------|--------|-------|----------------------------------------------------------------------------------------------| +| SCA-N-01 | `krci sca get {{CODEBASE_NONE}}` | portal | — | `exit=0; stdout~/status: NONE/; stdout~/no SCA scanner bound for {{CODEBASE_NONE}}/` | +| SCA-N-02 | `krci sca get {{CODEBASE_NONE}} -o json` | portal | — | `exit=0; stdout-json~/\.data\.status == "NONE"/` | +| SCA-N-03 | `krci sca components {{CODEBASE_NONE}} -o json` | portal | — | `exit=0; stdout-json~/\.data\.status == "NONE"/; stdout-json~/\.data\.items == []/` | +| SCA-N-04 | `krci sca findings {{CODEBASE_NONE}} -o json` | portal | — | `exit=0; stdout-json~/\.data\.status == "NONE"/; stdout-json~/\.data\.truncated == false/` | + +## 5. Severity filter (env: `portal`) + +Filter is inclusive — `--severity=high` includes HIGH + CRITICAL rows. + +| ID | Command | Env | Setup | Expect | +|------------|---------------------------------------------------------------------|--------|-------|------------------------------------------------------------------------------------------------------| +| SCA-S-01 | `krci sca findings {{CODEBASE_OK}} --severity=critical -o json` | portal | — | `exit=0; stdout-json~/every item vulnerability.severity == "CRITICAL"/` | +| SCA-S-02 | `krci sca findings {{CODEBASE_OK}} --severity=high -o json` | portal | — | `exit=0; stdout-json~/every item vulnerability.severity in ["CRITICAL","HIGH"]/` | +| SCA-S-03 | `krci sca findings {{CODEBASE_OK}} --include-suppressed -o json` | portal | — | `exit=0; stdout-json~/\.data\.status == "OK"/` | + +## 6. Truncation (env: `portal`) + +Projects with more than 1000 findings should truncate + set flag. +Requires a project with a large CVE footprint — skip when `{{CODEBASE_OK}}` +has fewer than 1000 raw rows. + +| ID | Command | Env | Setup | Expect | +|------------|------------------------------------------------------------------------|--------|----------------------------------------|-----------------------------------------------------------------------------------------------------| +| SCA-T-01 | `krci sca findings {{CODEBASE_OK}} -o json` | portal | large project (>=1000 raw findings) | `exit=0; stdout-json~/\.data\.truncated == true/; stdout-json~/\.data\.items length == 1000/` | +| SCA-T-02 | `krci sca findings {{CODEBASE_OK}}` | portal | large project | `exit=0; stdout~/truncated to 1000 rows/` | + +## 7. Default-branch resolution (env: `portal`) + +When `--branch` is omitted, the Portal reads `Codebase.spec.defaultBranch`. + +| ID | Command | Env | Setup | Expect | +|------------|-------------------------------------------------------------------|--------|----------------------------------------------|----------------------------------------------------------------------------------------------| +| SCA-B-01 | `krci sca get {{CODEBASE_OK}}` | portal | spec.defaultBranch set | `exit=0; stdout~/{{CODEBASE_OK}} @ /` | +| SCA-B-02 | `krci sca get {{CODEBASE_MISSING}}` | portal | no Codebase CR | `exit=1; stderr~/codebase {{CODEBASE_MISSING}} not found/; stderr~/krci sca list --search=/` | + +## 8. Error envelopes (env: `portal`) + +Script-friendly error payloads when `-o json` is set. + +| ID | Command | Env | Setup | Expect | +|------------|-------------------------------------------------------------------|--------|-------|------------------------------------------------------------------------------------------------------------| +| SCA-E-01 | `krci sca get {{CODEBASE_MISSING}} -o json` | portal | — | `exit=1; stdout-json~/\.schemaVersion == "1"/; stdout-json~/\.error\.message contains "not found"/` | +| SCA-E-02 | `krci sca findings {{CODEBASE_MISSING}} -o json` | portal | — | `exit=1; stdout-json~/\.error\.message contains "not found"/` | diff --git a/internal/output/output.go b/internal/output/output.go index ecca352..682f3ab 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -223,6 +223,28 @@ func PrintJSONErrorEnvelope(w io.Writer, schemaVersion string, err error) error }) } +// SCASeverityColor colorizes a Dep-Track vulnerability severity for TTY +// rendering. Unknown severities pass through unstyled so future values never +// break existing renderers. +// +// CRITICAL → red HIGH → yellow +// MEDIUM → blue LOW → gray +// INFO → gray UNASSIGNED→ gray +func SCASeverityColor(severity string) string { + switch strings.ToUpper(severity) { + case "CRITICAL": + return redStyle.Render(severity) + case "HIGH": + return yellowStyle.Render(severity) + case "MEDIUM": + return blueStyle.Render(severity) + case "LOW", "INFO", "UNASSIGNED": + return grayStyle.Render(severity) + default: + return severity + } +} + // SonarGateStatusColor colorizes a SonarQube quality-gate / issue status value. // Unknown values pass through unstyled. func SonarGateStatusColor(status portal.QualityGateStatus) string { diff --git a/internal/portal/config.go b/internal/portal/config.go index 82ab2d1..5b16e90 100644 --- a/internal/portal/config.go +++ b/internal/portal/config.go @@ -41,7 +41,7 @@ func fetchOIDCConfig(portalURL string) (string, error) { } if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("OIDC config returned HTTP %d: %s", resp.StatusCode, truncate(body, 200)) + return "", fmt.Errorf("OIDC config returned HTTP %d: %s", resp.StatusCode, truncateBody(body)) } var result struct { diff --git a/internal/portal/errors.go b/internal/portal/errors.go index 430caef..493f5b7 100644 --- a/internal/portal/errors.go +++ b/internal/portal/errors.go @@ -6,7 +6,8 @@ import ( // Sentinel errors for portal API failures. var ( - ErrUnauthorized = errors.New("unauthorized: please run 'krci auth login'") - ErrNotFound = errors.New("resource not found") - ErrHTTPSRequired = errors.New("portal URL must use HTTPS") + ErrUnauthorized = errors.New("unauthorized: please run 'krci auth login'") + ErrNotFound = errors.New("resource not found") + ErrHTTPSRequired = errors.New("portal URL must use HTTPS") + ErrUpstreamUnavailable = errors.New("upstream service unavailable") ) diff --git a/internal/portal/openapi/spec.json b/internal/portal/openapi/spec.json index 7979f86..47f5d1d 100644 --- a/internal/portal/openapi/spec.json +++ b/internal/portal/openapi/spec.json @@ -1333,6 +1333,496 @@ } } }, + "/v1/sca/list": { + "get": { + "operationId": "sca-list", + "tags": [ + "sca" + ], + "description": "List Dependency-Track projects known to the Portal. Returns `{ items, totalCount }` envelope; an empty Dep-Track portfolio yields `items: []` with `totalCount: 0`. Authentication required (401 when the session/bearer token is missing). Upstream failures: 502 Bad Gateway when the Dep-Track instance is unreachable, 503 Service Unavailable when the Portal's DEPENDENCY_TRACK_URL / DEPENDENCY_TRACK_API_KEY environment is unset.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "in": "query", + "name": "pageNumber", + "schema": { + "type": "integer", + "minimum": 1 + } + }, + { + "in": "query", + "name": "pageSize", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 500 + } + }, + { + "in": "query", + "name": "searchTerm", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "onlyRoot", + "schema": { + "type": "string", + "enum": [ + "true", + "false" + ] + } + }, + { + "in": "query", + "name": "excludeInactive", + "schema": { + "type": "string", + "enum": [ + "true", + "false" + ] + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SCAListResponse" + } + } + } + }, + "400": { + "description": "Invalid input data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } + }, + "401": { + "description": "Authorization not provided", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } + }, + "502": { + "description": "Upstream Dep-Track unreachable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } + } + }, + "503": { + "description": "Dep-Track integration not configured", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } + } + } + } + } + }, + "/v1/sca/get": { + "get": { + "operationId": "sca-get", + "tags": [ + "sca" + ], + "description": "Return Dep-Track project overview for `(codebase, branch)`. When `branch` is omitted, the Portal resolves `Codebase.spec.defaultBranch` from the Kubernetes API and uses that value. A missing Codebase CR or empty `spec.defaultBranch` yields 404; a missing Dep-Track project yields 200 with `{ status: \"NONE\" }`. Authentication required. Upstream failures: 502 unreachable, 503 unconfigured.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "in": "query", + "name": "codebase", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "query", + "name": "branch", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SCAGetResponse" + } + } + } + }, + "400": { + "description": "Invalid input data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } + }, + "401": { + "description": "Authorization not provided", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } + }, + "404": { + "description": "Codebase not found or default branch missing", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.NOT_FOUND" + } + } + } + }, + "502": { + "description": "Upstream Dep-Track unreachable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } + } + }, + "503": { + "description": "Dep-Track integration not configured", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } + } + } + } + } + }, + "/v1/sca/components": { + "get": { + "operationId": "sca-components", + "tags": [ + "sca" + ], + "description": "Return paginated dependencies for `(codebase, branch)`. Branch resolution + NONE state semantics identical to `/v1/sca/get`. `onlyOutdated` / `onlyDirect` are forwarded to Dep-Track as server-side filters. Authentication required. Upstream failures: 502 / 503 as above.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "in": "query", + "name": "codebase", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "query", + "name": "branch", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "pageNumber", + "schema": { + "type": "integer", + "minimum": 1 + } + }, + { + "in": "query", + "name": "pageSize", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 500 + } + }, + { + "in": "query", + "name": "onlyOutdated", + "schema": { + "type": "string", + "enum": [ + "true", + "false" + ] + } + }, + { + "in": "query", + "name": "onlyDirect", + "schema": { + "type": "string", + "enum": [ + "true", + "false" + ] + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SCAComponentsResponse" + } + } + } + }, + "400": { + "description": "Invalid input data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } + }, + "401": { + "description": "Authorization not provided", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } + }, + "404": { + "description": "Codebase not found or default branch missing", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.NOT_FOUND" + } + } + } + }, + "502": { + "description": "Upstream Dep-Track unreachable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } + } + }, + "503": { + "description": "Dep-Track integration not configured", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } + } + } + } + } + }, + "/v1/sca/findings": { + "get": { + "operationId": "sca-findings", + "tags": [ + "sca" + ], + "description": "Return vulnerability findings for `(codebase, branch)`. Dep-Track returns all findings in a single upstream response; the Portal caps the response at 1000 rows and sets `truncated=true` when capped. Default excludes suppressed rows unless `suppressed=true`. Authentication required. Upstream failures: 502 / 503 as above.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "in": "query", + "name": "codebase", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "query", + "name": "branch", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "suppressed", + "schema": { + "type": "string", + "enum": [ + "true", + "false" + ] + } + }, + { + "in": "query", + "name": "source", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SCAFindingsResponse" + } + } + } + }, + "400": { + "description": "Invalid input data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } + }, + "401": { + "description": "Authorization not provided", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } + }, + "404": { + "description": "Codebase not found or default branch missing", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.NOT_FOUND" + } + } + } + }, + "502": { + "description": "Upstream Dep-Track unreachable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } + } + }, + "503": { + "description": "Dep-Track integration not configured", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } + } + } + } + } + }, "/v1/sonar/list": { "get": { "operationId": "sonar-list", @@ -2058,6 +2548,316 @@ "status" ] }, + "SCAMetrics": { + "type": "object", + "description": "Vulnerability-count metrics for a project or component.", + "properties": { + "critical": { + "type": "integer" + }, + "high": { + "type": "integer" + }, + "medium": { + "type": "integer" + }, + "low": { + "type": "integer" + }, + "unassigned": { + "type": "integer" + }, + "vulnerabilities": { + "type": "integer" + }, + "components": { + "type": "integer" + }, + "vulnerableComponents": { + "type": "integer" + } + } + }, + "SCAProject": { + "type": "object", + "properties": { + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "classifier": { + "type": "string" + }, + "active": { + "type": "boolean" + }, + "isLatest": { + "type": "boolean" + }, + "lastBomImport": { + "type": "integer", + "format": "int64" + }, + "lastBomImportFormat": { + "type": "string" + }, + "lastVulnerabilityAnalysis": { + "type": "integer", + "format": "int64" + }, + "riskScore": { + "type": "number" + }, + "metrics": { + "$ref": "#/components/schemas/SCAMetrics" + } + }, + "required": [ + "uuid", + "name", + "version" + ] + }, + "SCAListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SCAProject" + } + }, + "totalCount": { + "type": "integer" + } + }, + "required": [ + "items", + "totalCount" + ] + }, + "SCAGetResponse": { + "type": "object", + "description": "Either `{ status: \"OK\", project, metrics }` or `{ status: \"NONE\" }` when the codebase has no Dep-Track binding.", + "properties": { + "status": { + "type": "string", + "enum": [ + "OK", + "NONE" + ] + }, + "project": { + "$ref": "#/components/schemas/SCAProject" + }, + "metrics": { + "$ref": "#/components/schemas/SCAMetrics" + } + }, + "required": [ + "status" + ] + }, + "SCAComponent": { + "type": "object", + "properties": { + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "latestVersion": { + "type": "string" + }, + "outdated": { + "type": "boolean" + }, + "group": { + "type": "string" + }, + "license": { + "type": "string" + }, + "isInternal": { + "type": "boolean" + }, + "riskScore": { + "type": "number" + }, + "metrics": { + "$ref": "#/components/schemas/SCAMetrics" + } + }, + "required": [ + "uuid", + "name", + "version" + ] + }, + "SCAComponentsResponse": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "OK", + "NONE" + ] + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SCAComponent" + } + }, + "totalCount": { + "type": "integer" + } + }, + "required": [ + "status", + "items", + "totalCount" + ] + }, + "SCAFindingComponent": { + "type": "object", + "properties": { + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "group": { + "type": "string" + } + }, + "required": [ + "uuid", + "name" + ] + }, + "SCAFindingVulnerability": { + "type": "object", + "properties": { + "vulnId": { + "type": "string" + }, + "source": { + "type": "string" + }, + "severity": { + "type": "string", + "enum": [ + "CRITICAL", + "HIGH", + "MEDIUM", + "LOW", + "INFO", + "UNASSIGNED" + ] + }, + "cvssV3BaseScore": { + "type": "number" + }, + "cvssV2BaseScore": { + "type": "number" + } + }, + "required": [ + "vulnId", + "source", + "severity" + ] + }, + "SCAFindingAnalysis": { + "type": "object", + "properties": { + "state": { + "type": "string" + }, + "isSuppressed": { + "type": "boolean" + } + }, + "required": [ + "state", + "isSuppressed" + ] + }, + "SCAFindingAttribution": { + "type": "object", + "properties": { + "analyzerIdentity": { + "type": "string" + }, + "attributedOn": { + "type": "integer", + "format": "int64" + } + } + }, + "SCAFinding": { + "type": "object", + "properties": { + "component": { + "$ref": "#/components/schemas/SCAFindingComponent" + }, + "vulnerability": { + "$ref": "#/components/schemas/SCAFindingVulnerability" + }, + "analysis": { + "$ref": "#/components/schemas/SCAFindingAnalysis" + }, + "attribution": { + "$ref": "#/components/schemas/SCAFindingAttribution" + } + }, + "required": [ + "component", + "vulnerability", + "analysis" + ] + }, + "SCAFindingsResponse": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "OK", + "NONE" + ] + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SCAFinding" + } + }, + "truncated": { + "type": "boolean" + } + }, + "required": [ + "status", + "items", + "truncated" + ] + }, "SonarGate": { "type": "object", "properties": { diff --git a/internal/portal/rest.go b/internal/portal/rest.go index 0ecc9b5..1057d68 100644 --- a/internal/portal/rest.go +++ b/internal/portal/rest.go @@ -5,6 +5,10 @@ import ( "net/http" ) +// maxErrorBodyLen bounds the portion of a portal error body that is echoed back +// to the user. Keeps stderr readable and avoids dumping long HTML error pages. +const maxErrorBodyLen = 200 + // checkResponse maps HTTP status codes to domain errors. func checkResponse(statusCode int, body []byte) error { switch statusCode { @@ -15,14 +19,17 @@ func checkResponse(statusCode int, body []byte) error { case http.StatusNotFound: return ErrNotFound default: - return fmt.Errorf("portal returned HTTP %d: %s", statusCode, truncate(body, 200)) + return fmt.Errorf("portal returned HTTP %d: %s", statusCode, truncateBody(body)) } } -func truncate(b []byte, max int) string { - if len(b) <= max { +// truncateBody shortens a server-provided byte body to maxErrorBodyLen ASCII +// characters, appending "..." when the body was cut. Shared by every caller +// that needs to surface a remote response body in an error message. +func truncateBody(b []byte) string { + if len(b) <= maxErrorBodyLen { return string(b) } - return string(b[:max]) + "..." + return string(b[:maxErrorBodyLen]) + "..." } diff --git a/internal/portal/restapi/api_gen.go b/internal/portal/restapi/api_gen.go index 42018f1..51b761c 100644 --- a/internal/portal/restapi/api_gen.go +++ b/internal/portal/restapi/api_gen.go @@ -22,6 +22,34 @@ const ( BearerAuthScopes = "bearerAuth.Scopes" ) +// Defines values for SCAComponentsResponseStatus. +const ( + SCAComponentsResponseStatusNONE SCAComponentsResponseStatus = "NONE" + SCAComponentsResponseStatusOK SCAComponentsResponseStatus = "OK" +) + +// Defines values for SCAFindingVulnerabilitySeverity. +const ( + SCAFindingVulnerabilitySeverityCRITICAL SCAFindingVulnerabilitySeverity = "CRITICAL" + SCAFindingVulnerabilitySeverityHIGH SCAFindingVulnerabilitySeverity = "HIGH" + SCAFindingVulnerabilitySeverityINFO SCAFindingVulnerabilitySeverity = "INFO" + SCAFindingVulnerabilitySeverityLOW SCAFindingVulnerabilitySeverity = "LOW" + SCAFindingVulnerabilitySeverityMEDIUM SCAFindingVulnerabilitySeverity = "MEDIUM" + SCAFindingVulnerabilitySeverityUNASSIGNED SCAFindingVulnerabilitySeverity = "UNASSIGNED" +) + +// Defines values for SCAFindingsResponseStatus. +const ( + SCAFindingsResponseStatusNONE SCAFindingsResponseStatus = "NONE" + SCAFindingsResponseStatusOK SCAFindingsResponseStatus = "OK" +) + +// Defines values for SCAGetResponseStatus. +const ( + SCAGetResponseStatusNONE SCAGetResponseStatus = "NONE" + SCAGetResponseStatusOK SCAGetResponseStatus = "OK" +) + // Defines values for SonarGateProjectStatusStatus. const ( SonarGateProjectStatusStatusERROR SonarGateProjectStatusStatus = "ERROR" @@ -40,11 +68,11 @@ const ( // Defines values for SonarIssueSeverity. const ( - BLOCKER SonarIssueSeverity = "BLOCKER" - CRITICAL SonarIssueSeverity = "CRITICAL" - INFO SonarIssueSeverity = "INFO" - MAJOR SonarIssueSeverity = "MAJOR" - MINOR SonarIssueSeverity = "MINOR" + SonarIssueSeverityBLOCKER SonarIssueSeverity = "BLOCKER" + SonarIssueSeverityCRITICAL SonarIssueSeverity = "CRITICAL" + SonarIssueSeverityINFO SonarIssueSeverity = "INFO" + SonarIssueSeverityMAJOR SonarIssueSeverity = "MAJOR" + SonarIssueSeverityMINOR SonarIssueSeverity = "MINOR" ) // Defines values for SonarIssueStatus. @@ -73,10 +101,10 @@ const ( // Defines values for SonarProjectDetailQualityGateStatus. const ( - SonarProjectDetailQualityGateStatusERROR SonarProjectDetailQualityGateStatus = "ERROR" - SonarProjectDetailQualityGateStatusNONE SonarProjectDetailQualityGateStatus = "NONE" - SonarProjectDetailQualityGateStatusOK SonarProjectDetailQualityGateStatus = "OK" - SonarProjectDetailQualityGateStatusWARN SonarProjectDetailQualityGateStatus = "WARN" + ERROR SonarProjectDetailQualityGateStatus = "ERROR" + NONE SonarProjectDetailQualityGateStatus = "NONE" + OK SonarProjectDetailQualityGateStatus = "OK" + WARN SonarProjectDetailQualityGateStatus = "WARN" ) // Defines values for TektonResultSummaryStatus. @@ -88,6 +116,36 @@ const ( UNKNOWN TektonResultSummaryStatus = "UNKNOWN" ) +// Defines values for ScaComponentsParamsOnlyOutdated. +const ( + ScaComponentsParamsOnlyOutdatedFalse ScaComponentsParamsOnlyOutdated = "false" + ScaComponentsParamsOnlyOutdatedTrue ScaComponentsParamsOnlyOutdated = "true" +) + +// Defines values for ScaComponentsParamsOnlyDirect. +const ( + ScaComponentsParamsOnlyDirectFalse ScaComponentsParamsOnlyDirect = "false" + ScaComponentsParamsOnlyDirectTrue ScaComponentsParamsOnlyDirect = "true" +) + +// Defines values for ScaFindingsParamsSuppressed. +const ( + ScaFindingsParamsSuppressedFalse ScaFindingsParamsSuppressed = "false" + ScaFindingsParamsSuppressedTrue ScaFindingsParamsSuppressed = "true" +) + +// Defines values for ScaListParamsOnlyRoot. +const ( + ScaListParamsOnlyRootFalse ScaListParamsOnlyRoot = "false" + ScaListParamsOnlyRootTrue ScaListParamsOnlyRoot = "true" +) + +// Defines values for ScaListParamsExcludeInactive. +const ( + ScaListParamsExcludeInactiveFalse ScaListParamsExcludeInactive = "false" + ScaListParamsExcludeInactiveTrue ScaListParamsExcludeInactive = "true" +) + // Defines values for SonarIssuesParamsResolved. const ( SonarIssuesParamsResolvedFalse SonarIssuesParamsResolved = "false" @@ -96,8 +154,8 @@ const ( // Defines values for SonarIssuesParamsAsc. const ( - SonarIssuesParamsAscFalse SonarIssuesParamsAsc = "false" - SonarIssuesParamsAscTrue SonarIssuesParamsAsc = "true" + False SonarIssuesParamsAsc = "false" + True SonarIssuesParamsAsc = "true" ) // PipelineRunResultsResponse defines model for PipelineRunResultsResponse. @@ -106,6 +164,128 @@ type PipelineRunResultsResponse struct { Results []TektonResult `json:"results"` } +// SCAComponent defines model for SCAComponent. +type SCAComponent struct { + Group *string `json:"group,omitempty"` + IsInternal *bool `json:"isInternal,omitempty"` + LatestVersion *string `json:"latestVersion,omitempty"` + License *string `json:"license,omitempty"` + + // Metrics Vulnerability-count metrics for a project or component. + Metrics *SCAMetrics `json:"metrics,omitempty"` + Name string `json:"name"` + Outdated *bool `json:"outdated,omitempty"` + RiskScore *float32 `json:"riskScore,omitempty"` + Uuid string `json:"uuid"` + Version string `json:"version"` +} + +// SCAComponentsResponse defines model for SCAComponentsResponse. +type SCAComponentsResponse struct { + Items []SCAComponent `json:"items"` + Status SCAComponentsResponseStatus `json:"status"` + TotalCount int `json:"totalCount"` +} + +// SCAComponentsResponseStatus defines model for SCAComponentsResponse.Status. +type SCAComponentsResponseStatus string + +// SCAFinding defines model for SCAFinding. +type SCAFinding struct { + Analysis SCAFindingAnalysis `json:"analysis"` + Attribution *SCAFindingAttribution `json:"attribution,omitempty"` + Component SCAFindingComponent `json:"component"` + Vulnerability SCAFindingVulnerability `json:"vulnerability"` +} + +// SCAFindingAnalysis defines model for SCAFindingAnalysis. +type SCAFindingAnalysis struct { + IsSuppressed bool `json:"isSuppressed"` + State string `json:"state"` +} + +// SCAFindingAttribution defines model for SCAFindingAttribution. +type SCAFindingAttribution struct { + AnalyzerIdentity *string `json:"analyzerIdentity,omitempty"` + AttributedOn *int64 `json:"attributedOn,omitempty"` +} + +// SCAFindingComponent defines model for SCAFindingComponent. +type SCAFindingComponent struct { + Group *string `json:"group,omitempty"` + Name string `json:"name"` + Uuid string `json:"uuid"` + Version *string `json:"version,omitempty"` +} + +// SCAFindingVulnerability defines model for SCAFindingVulnerability. +type SCAFindingVulnerability struct { + CvssV2BaseScore *float32 `json:"cvssV2BaseScore,omitempty"` + CvssV3BaseScore *float32 `json:"cvssV3BaseScore,omitempty"` + Severity SCAFindingVulnerabilitySeverity `json:"severity"` + Source string `json:"source"` + VulnId string `json:"vulnId"` +} + +// SCAFindingVulnerabilitySeverity defines model for SCAFindingVulnerability.Severity. +type SCAFindingVulnerabilitySeverity string + +// SCAFindingsResponse defines model for SCAFindingsResponse. +type SCAFindingsResponse struct { + Items []SCAFinding `json:"items"` + Status SCAFindingsResponseStatus `json:"status"` + Truncated bool `json:"truncated"` +} + +// SCAFindingsResponseStatus defines model for SCAFindingsResponse.Status. +type SCAFindingsResponseStatus string + +// SCAGetResponse Either `{ status: "OK", project, metrics }` or `{ status: "NONE" }` when the codebase has no Dep-Track binding. +type SCAGetResponse struct { + // Metrics Vulnerability-count metrics for a project or component. + Metrics *SCAMetrics `json:"metrics,omitempty"` + Project *SCAProject `json:"project,omitempty"` + Status SCAGetResponseStatus `json:"status"` +} + +// SCAGetResponseStatus defines model for SCAGetResponse.Status. +type SCAGetResponseStatus string + +// SCAListResponse defines model for SCAListResponse. +type SCAListResponse struct { + Items []SCAProject `json:"items"` + TotalCount int `json:"totalCount"` +} + +// SCAMetrics Vulnerability-count metrics for a project or component. +type SCAMetrics struct { + Components *int `json:"components,omitempty"` + Critical *int `json:"critical,omitempty"` + High *int `json:"high,omitempty"` + Low *int `json:"low,omitempty"` + Medium *int `json:"medium,omitempty"` + Unassigned *int `json:"unassigned,omitempty"` + Vulnerabilities *int `json:"vulnerabilities,omitempty"` + VulnerableComponents *int `json:"vulnerableComponents,omitempty"` +} + +// SCAProject defines model for SCAProject. +type SCAProject struct { + Active *bool `json:"active,omitempty"` + Classifier *string `json:"classifier,omitempty"` + IsLatest *bool `json:"isLatest,omitempty"` + LastBomImport *int64 `json:"lastBomImport,omitempty"` + LastBomImportFormat *string `json:"lastBomImportFormat,omitempty"` + LastVulnerabilityAnalysis *int64 `json:"lastVulnerabilityAnalysis,omitempty"` + + // Metrics Vulnerability-count metrics for a project or component. + Metrics *SCAMetrics `json:"metrics,omitempty"` + Name string `json:"name"` + RiskScore *float32 `json:"riskScore,omitempty"` + Uuid string `json:"uuid"` + Version string `json:"version"` +} + // SonarGate defines model for SonarGate. type SonarGate struct { ProjectStatus struct { @@ -482,6 +662,54 @@ type K8sListJSONBody struct { } `json:"resourceConfig"` } +// ScaComponentsParams defines parameters for ScaComponents. +type ScaComponentsParams struct { + Codebase string `form:"codebase" json:"codebase"` + Branch *string `form:"branch,omitempty" json:"branch,omitempty"` + PageNumber *int `form:"pageNumber,omitempty" json:"pageNumber,omitempty"` + PageSize *int `form:"pageSize,omitempty" json:"pageSize,omitempty"` + OnlyOutdated *ScaComponentsParamsOnlyOutdated `form:"onlyOutdated,omitempty" json:"onlyOutdated,omitempty"` + OnlyDirect *ScaComponentsParamsOnlyDirect `form:"onlyDirect,omitempty" json:"onlyDirect,omitempty"` +} + +// ScaComponentsParamsOnlyOutdated defines parameters for ScaComponents. +type ScaComponentsParamsOnlyOutdated string + +// ScaComponentsParamsOnlyDirect defines parameters for ScaComponents. +type ScaComponentsParamsOnlyDirect string + +// ScaFindingsParams defines parameters for ScaFindings. +type ScaFindingsParams struct { + Codebase string `form:"codebase" json:"codebase"` + Branch *string `form:"branch,omitempty" json:"branch,omitempty"` + Suppressed *ScaFindingsParamsSuppressed `form:"suppressed,omitempty" json:"suppressed,omitempty"` + Source *string `form:"source,omitempty" json:"source,omitempty"` +} + +// ScaFindingsParamsSuppressed defines parameters for ScaFindings. +type ScaFindingsParamsSuppressed string + +// ScaGetParams defines parameters for ScaGet. +type ScaGetParams struct { + Codebase string `form:"codebase" json:"codebase"` + Branch *string `form:"branch,omitempty" json:"branch,omitempty"` +} + +// ScaListParams defines parameters for ScaList. +type ScaListParams struct { + PageNumber *int `form:"pageNumber,omitempty" json:"pageNumber,omitempty"` + PageSize *int `form:"pageSize,omitempty" json:"pageSize,omitempty"` + SearchTerm *string `form:"searchTerm,omitempty" json:"searchTerm,omitempty"` + OnlyRoot *ScaListParamsOnlyRoot `form:"onlyRoot,omitempty" json:"onlyRoot,omitempty"` + ExcludeInactive *ScaListParamsExcludeInactive `form:"excludeInactive,omitempty" json:"excludeInactive,omitempty"` +} + +// ScaListParamsOnlyRoot defines parameters for ScaList. +type ScaListParamsOnlyRoot string + +// ScaListParamsExcludeInactive defines parameters for ScaList. +type ScaListParamsExcludeInactive string + // SonarGateParams defines parameters for SonarGate. type SonarGateParams struct { ProjectKey string `form:"projectKey" json:"projectKey"` @@ -635,6 +863,18 @@ type ClientInterface interface { K8sList(ctx context.Context, body K8sListJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // ScaComponents request + ScaComponents(ctx context.Context, params *ScaComponentsParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // ScaFindings request + ScaFindings(ctx context.Context, params *ScaFindingsParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // ScaGet request + ScaGet(ctx context.Context, params *ScaGetParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // ScaList request + ScaList(ctx context.Context, params *ScaListParams, reqEditors ...RequestEditorFn) (*http.Response, error) + // SonarGate request SonarGate(ctx context.Context, params *SonarGateParams, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -759,6 +999,54 @@ func (c *Client) K8sList(ctx context.Context, body K8sListJSONRequestBody, reqEd return c.Client.Do(req) } +func (c *Client) ScaComponents(ctx context.Context, params *ScaComponentsParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewScaComponentsRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ScaFindings(ctx context.Context, params *ScaFindingsParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewScaFindingsRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ScaGet(ctx context.Context, params *ScaGetParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewScaGetRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ScaList(ctx context.Context, params *ScaListParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewScaListRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) SonarGate(ctx context.Context, params *SonarGateParams, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewSonarGateRequest(c.Server, params) if err != nil { @@ -1162,8 +1450,8 @@ func NewK8sListRequestWithBody(server string, contentType string, body io.Reader return req, nil } -// NewSonarGateRequest generates requests for SonarGate -func NewSonarGateRequest(server string, params *SonarGateParams) (*http.Request, error) { +// NewScaComponentsRequest generates requests for ScaComponents +func NewScaComponentsRequest(server string, params *ScaComponentsParams) (*http.Request, error) { var err error serverURL, err := url.Parse(server) @@ -1171,7 +1459,7 @@ func NewSonarGateRequest(server string, params *SonarGateParams) (*http.Request, return nil, err } - operationPath := fmt.Sprintf("/v1/sonar/gate") + operationPath := fmt.Sprintf("/v1/sca/components") if operationPath[0] == '/' { operationPath = "." + operationPath } @@ -1184,7 +1472,7 @@ func NewSonarGateRequest(server string, params *SonarGateParams) (*http.Request, if params != nil { queryValues := queryURL.Query() - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "projectKey", runtime.ParamLocationQuery, params.ProjectKey); err != nil { + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "codebase", runtime.ParamLocationQuery, params.Codebase); err != nil { return nil, err } else if parsed, err := url.ParseQuery(queryFrag); err != nil { return nil, err @@ -1196,9 +1484,9 @@ func NewSonarGateRequest(server string, params *SonarGateParams) (*http.Request, } } - if params.PullRequest != nil { + if params.Branch != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "pullRequest", runtime.ParamLocationQuery, *params.PullRequest); err != nil { + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "branch", runtime.ParamLocationQuery, *params.Branch); err != nil { return nil, err } else if parsed, err := url.ParseQuery(queryFrag); err != nil { return nil, err @@ -1212,9 +1500,57 @@ func NewSonarGateRequest(server string, params *SonarGateParams) (*http.Request, } - if params.Branch != nil { + if params.PageNumber != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "branch", runtime.ParamLocationQuery, *params.Branch); err != nil { + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "pageNumber", runtime.ParamLocationQuery, *params.PageNumber); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + if params.PageSize != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "pageSize", runtime.ParamLocationQuery, *params.PageSize); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + if params.OnlyOutdated != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "onlyOutdated", runtime.ParamLocationQuery, *params.OnlyOutdated); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + if params.OnlyDirect != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "onlyDirect", runtime.ParamLocationQuery, *params.OnlyDirect); err != nil { return nil, err } else if parsed, err := url.ParseQuery(queryFrag); err != nil { return nil, err @@ -1239,8 +1575,8 @@ func NewSonarGateRequest(server string, params *SonarGateParams) (*http.Request, return req, nil } -// NewSonarGetRequest generates requests for SonarGet -func NewSonarGetRequest(server string, params *SonarGetParams) (*http.Request, error) { +// NewScaFindingsRequest generates requests for ScaFindings +func NewScaFindingsRequest(server string, params *ScaFindingsParams) (*http.Request, error) { var err error serverURL, err := url.Parse(server) @@ -1248,7 +1584,7 @@ func NewSonarGetRequest(server string, params *SonarGetParams) (*http.Request, e return nil, err } - operationPath := fmt.Sprintf("/v1/sonar/get") + operationPath := fmt.Sprintf("/v1/sca/findings") if operationPath[0] == '/' { operationPath = "." + operationPath } @@ -1261,7 +1597,7 @@ func NewSonarGetRequest(server string, params *SonarGetParams) (*http.Request, e if params != nil { queryValues := queryURL.Query() - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "projectKey", runtime.ParamLocationQuery, params.ProjectKey); err != nil { + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "codebase", runtime.ParamLocationQuery, params.Codebase); err != nil { return nil, err } else if parsed, err := url.ParseQuery(queryFrag); err != nil { return nil, err @@ -1273,9 +1609,9 @@ func NewSonarGetRequest(server string, params *SonarGetParams) (*http.Request, e } } - if params.PullRequest != nil { + if params.Branch != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "pullRequest", runtime.ParamLocationQuery, *params.PullRequest); err != nil { + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "branch", runtime.ParamLocationQuery, *params.Branch); err != nil { return nil, err } else if parsed, err := url.ParseQuery(queryFrag); err != nil { return nil, err @@ -1289,9 +1625,25 @@ func NewSonarGetRequest(server string, params *SonarGetParams) (*http.Request, e } - if params.Branch != nil { + if params.Suppressed != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "branch", runtime.ParamLocationQuery, *params.Branch); err != nil { + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "suppressed", runtime.ParamLocationQuery, *params.Suppressed); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + if params.Source != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "source", runtime.ParamLocationQuery, *params.Source); err != nil { return nil, err } else if parsed, err := url.ParseQuery(queryFrag); err != nil { return nil, err @@ -1316,8 +1668,8 @@ func NewSonarGetRequest(server string, params *SonarGetParams) (*http.Request, e return req, nil } -// NewSonarIssuesRequest generates requests for SonarIssues -func NewSonarIssuesRequest(server string, params *SonarIssuesParams) (*http.Request, error) { +// NewScaGetRequest generates requests for ScaGet +func NewScaGetRequest(server string, params *ScaGetParams) (*http.Request, error) { var err error serverURL, err := url.Parse(server) @@ -1325,7 +1677,7 @@ func NewSonarIssuesRequest(server string, params *SonarIssuesParams) (*http.Requ return nil, err } - operationPath := fmt.Sprintf("/v1/sonar/issues") + operationPath := fmt.Sprintf("/v1/sca/get") if operationPath[0] == '/' { operationPath = "." + operationPath } @@ -1338,7 +1690,7 @@ func NewSonarIssuesRequest(server string, params *SonarIssuesParams) (*http.Requ if params != nil { queryValues := queryURL.Query() - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "projectKey", runtime.ParamLocationQuery, params.ProjectKey); err != nil { + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "codebase", runtime.ParamLocationQuery, params.Codebase); err != nil { return nil, err } else if parsed, err := url.ParseQuery(queryFrag); err != nil { return nil, err @@ -1350,9 +1702,9 @@ func NewSonarIssuesRequest(server string, params *SonarIssuesParams) (*http.Requ } } - if params.PullRequest != nil { + if params.Branch != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "pullRequest", runtime.ParamLocationQuery, *params.PullRequest); err != nil { + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "branch", runtime.ParamLocationQuery, *params.Branch); err != nil { return nil, err } else if parsed, err := url.ParseQuery(queryFrag); err != nil { return nil, err @@ -1366,25 +1718,42 @@ func NewSonarIssuesRequest(server string, params *SonarIssuesParams) (*http.Requ } - if params.Branch != nil { + queryURL.RawQuery = queryValues.Encode() + } - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "branch", runtime.ParamLocationQuery, *params.Branch); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != nil { - return nil, err - } else { - for k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } - } - } + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } - } + return req, nil +} - if params.Types != nil { +// NewScaListRequest generates requests for ScaList +func NewScaListRequest(server string, params *ScaListParams) (*http.Request, error) { + var err error - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "types", runtime.ParamLocationQuery, *params.Types); err != nil { + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/v1/sca/list") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + queryValues := queryURL.Query() + + if params.PageNumber != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "pageNumber", runtime.ParamLocationQuery, *params.PageNumber); err != nil { return nil, err } else if parsed, err := url.ParseQuery(queryFrag); err != nil { return nil, err @@ -1398,9 +1767,9 @@ func NewSonarIssuesRequest(server string, params *SonarIssuesParams) (*http.Requ } - if params.Severities != nil { + if params.PageSize != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "severities", runtime.ParamLocationQuery, *params.Severities); err != nil { + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "pageSize", runtime.ParamLocationQuery, *params.PageSize); err != nil { return nil, err } else if parsed, err := url.ParseQuery(queryFrag); err != nil { return nil, err @@ -1414,9 +1783,9 @@ func NewSonarIssuesRequest(server string, params *SonarIssuesParams) (*http.Requ } - if params.Statuses != nil { + if params.SearchTerm != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "statuses", runtime.ParamLocationQuery, *params.Statuses); err != nil { + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "searchTerm", runtime.ParamLocationQuery, *params.SearchTerm); err != nil { return nil, err } else if parsed, err := url.ParseQuery(queryFrag); err != nil { return nil, err @@ -1430,9 +1799,9 @@ func NewSonarIssuesRequest(server string, params *SonarIssuesParams) (*http.Requ } - if params.Resolved != nil { + if params.OnlyRoot != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "resolved", runtime.ParamLocationQuery, *params.Resolved); err != nil { + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "onlyRoot", runtime.ParamLocationQuery, *params.OnlyRoot); err != nil { return nil, err } else if parsed, err := url.ParseQuery(queryFrag); err != nil { return nil, err @@ -1446,9 +1815,9 @@ func NewSonarIssuesRequest(server string, params *SonarIssuesParams) (*http.Requ } - if params.S != nil { + if params.ExcludeInactive != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "s", runtime.ParamLocationQuery, *params.S); err != nil { + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "excludeInactive", runtime.ParamLocationQuery, *params.ExcludeInactive); err != nil { return nil, err } else if parsed, err := url.ParseQuery(queryFrag); err != nil { return nil, err @@ -1462,8 +1831,319 @@ func NewSonarIssuesRequest(server string, params *SonarIssuesParams) (*http.Requ } - if params.Asc != nil { - + queryURL.RawQuery = queryValues.Encode() + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewSonarGateRequest generates requests for SonarGate +func NewSonarGateRequest(server string, params *SonarGateParams) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/v1/sonar/gate") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + queryValues := queryURL.Query() + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "projectKey", runtime.ParamLocationQuery, params.ProjectKey); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + if params.PullRequest != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "pullRequest", runtime.ParamLocationQuery, *params.PullRequest); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + if params.Branch != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "branch", runtime.ParamLocationQuery, *params.Branch); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + queryURL.RawQuery = queryValues.Encode() + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewSonarGetRequest generates requests for SonarGet +func NewSonarGetRequest(server string, params *SonarGetParams) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/v1/sonar/get") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + queryValues := queryURL.Query() + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "projectKey", runtime.ParamLocationQuery, params.ProjectKey); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + if params.PullRequest != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "pullRequest", runtime.ParamLocationQuery, *params.PullRequest); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + if params.Branch != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "branch", runtime.ParamLocationQuery, *params.Branch); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + queryURL.RawQuery = queryValues.Encode() + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewSonarIssuesRequest generates requests for SonarIssues +func NewSonarIssuesRequest(server string, params *SonarIssuesParams) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/v1/sonar/issues") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + queryValues := queryURL.Query() + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "projectKey", runtime.ParamLocationQuery, params.ProjectKey); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + if params.PullRequest != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "pullRequest", runtime.ParamLocationQuery, *params.PullRequest); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + if params.Branch != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "branch", runtime.ParamLocationQuery, *params.Branch); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + if params.Types != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "types", runtime.ParamLocationQuery, *params.Types); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + if params.Severities != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "severities", runtime.ParamLocationQuery, *params.Severities); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + if params.Statuses != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "statuses", runtime.ParamLocationQuery, *params.Statuses); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + if params.Resolved != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "resolved", runtime.ParamLocationQuery, *params.Resolved); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + if params.S != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "s", runtime.ParamLocationQuery, *params.S); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + if params.Asc != nil { + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "asc", runtime.ParamLocationQuery, *params.Asc); err != nil { return nil, err } else if parsed, err := url.ParseQuery(queryFrag); err != nil { @@ -1750,6 +2430,18 @@ type ClientWithResponsesInterface interface { K8sListWithResponse(ctx context.Context, body K8sListJSONRequestBody, reqEditors ...RequestEditorFn) (*K8sListResponse, error) + // ScaComponentsWithResponse request + ScaComponentsWithResponse(ctx context.Context, params *ScaComponentsParams, reqEditors ...RequestEditorFn) (*ScaComponentsResponse, error) + + // ScaFindingsWithResponse request + ScaFindingsWithResponse(ctx context.Context, params *ScaFindingsParams, reqEditors ...RequestEditorFn) (*ScaFindingsResponse, error) + + // ScaGetWithResponse request + ScaGetWithResponse(ctx context.Context, params *ScaGetParams, reqEditors ...RequestEditorFn) (*ScaGetResponse, error) + + // ScaListWithResponse request + ScaListWithResponse(ctx context.Context, params *ScaListParams, reqEditors ...RequestEditorFn) (*ScaListResponse, error) + // SonarGateWithResponse request SonarGateWithResponse(ctx context.Context, params *SonarGateParams, reqEditors ...RequestEditorFn) (*SonarGateResponse, error) @@ -1979,7 +2671,118 @@ type K8sList_200_Items_Item struct { } // Status returns HTTPResponse.Status -func (r K8sListResponse) Status() string { +func (r K8sListResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r K8sListResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type ScaComponentsResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *SCAComponentsResponse + JSON400 *ErrorBADREQUEST + JSON401 *ErrorUNAUTHORIZED + JSON404 *ErrorNOTFOUND + JSON500 *ErrorINTERNALSERVERERROR + JSON502 *ErrorINTERNALSERVERERROR + JSON503 *ErrorINTERNALSERVERERROR +} + +// Status returns HTTPResponse.Status +func (r ScaComponentsResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ScaComponentsResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type ScaFindingsResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *SCAFindingsResponse + JSON400 *ErrorBADREQUEST + JSON401 *ErrorUNAUTHORIZED + JSON404 *ErrorNOTFOUND + JSON500 *ErrorINTERNALSERVERERROR + JSON502 *ErrorINTERNALSERVERERROR + JSON503 *ErrorINTERNALSERVERERROR +} + +// Status returns HTTPResponse.Status +func (r ScaFindingsResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ScaFindingsResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type ScaGetResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *SCAGetResponse + JSON400 *ErrorBADREQUEST + JSON401 *ErrorUNAUTHORIZED + JSON404 *ErrorNOTFOUND + JSON500 *ErrorINTERNALSERVERERROR + JSON502 *ErrorINTERNALSERVERERROR + JSON503 *ErrorINTERNALSERVERERROR +} + +// Status returns HTTPResponse.Status +func (r ScaGetResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ScaGetResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type ScaListResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *SCAListResponse + JSON400 *ErrorBADREQUEST + JSON401 *ErrorUNAUTHORIZED + JSON500 *ErrorINTERNALSERVERERROR + JSON502 *ErrorINTERNALSERVERERROR + JSON503 *ErrorINTERNALSERVERERROR +} + +// Status returns HTTPResponse.Status +func (r ScaListResponse) Status() string { if r.HTTPResponse != nil { return r.HTTPResponse.Status } @@ -1987,7 +2790,7 @@ func (r K8sListResponse) Status() string { } // StatusCode returns HTTPResponse.StatusCode -func (r K8sListResponse) StatusCode() int { +func (r ScaListResponse) StatusCode() int { if r.HTTPResponse != nil { return r.HTTPResponse.StatusCode } @@ -2215,6 +3018,42 @@ func (c *ClientWithResponses) K8sListWithResponse(ctx context.Context, body K8sL return ParseK8sListResponse(rsp) } +// ScaComponentsWithResponse request returning *ScaComponentsResponse +func (c *ClientWithResponses) ScaComponentsWithResponse(ctx context.Context, params *ScaComponentsParams, reqEditors ...RequestEditorFn) (*ScaComponentsResponse, error) { + rsp, err := c.ScaComponents(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseScaComponentsResponse(rsp) +} + +// ScaFindingsWithResponse request returning *ScaFindingsResponse +func (c *ClientWithResponses) ScaFindingsWithResponse(ctx context.Context, params *ScaFindingsParams, reqEditors ...RequestEditorFn) (*ScaFindingsResponse, error) { + rsp, err := c.ScaFindings(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseScaFindingsResponse(rsp) +} + +// ScaGetWithResponse request returning *ScaGetResponse +func (c *ClientWithResponses) ScaGetWithResponse(ctx context.Context, params *ScaGetParams, reqEditors ...RequestEditorFn) (*ScaGetResponse, error) { + rsp, err := c.ScaGet(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseScaGetResponse(rsp) +} + +// ScaListWithResponse request returning *ScaListResponse +func (c *ClientWithResponses) ScaListWithResponse(ctx context.Context, params *ScaListParams, reqEditors ...RequestEditorFn) (*ScaListResponse, error) { + rsp, err := c.ScaList(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseScaListResponse(rsp) +} + // SonarGateWithResponse request returning *SonarGateResponse func (c *ClientWithResponses) SonarGateWithResponse(ctx context.Context, params *SonarGateParams, reqEditors ...RequestEditorFn) (*SonarGateResponse, error) { rsp, err := c.SonarGate(ctx, params, reqEditors...) @@ -2652,6 +3491,271 @@ func ParseK8sListResponse(rsp *http.Response) (*K8sListResponse, error) { return response, nil } +// ParseScaComponentsResponse parses an HTTP response from a ScaComponentsWithResponse call +func ParseScaComponentsResponse(rsp *http.Response) (*ScaComponentsResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ScaComponentsResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest SCAComponentsResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorBADREQUEST + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorUNAUTHORIZED + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorNOTFOUND + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorINTERNALSERVERERROR + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 502: + var dest ErrorINTERNALSERVERERROR + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON502 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorINTERNALSERVERERROR + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON503 = &dest + + } + + return response, nil +} + +// ParseScaFindingsResponse parses an HTTP response from a ScaFindingsWithResponse call +func ParseScaFindingsResponse(rsp *http.Response) (*ScaFindingsResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ScaFindingsResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest SCAFindingsResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorBADREQUEST + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorUNAUTHORIZED + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorNOTFOUND + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorINTERNALSERVERERROR + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 502: + var dest ErrorINTERNALSERVERERROR + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON502 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorINTERNALSERVERERROR + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON503 = &dest + + } + + return response, nil +} + +// ParseScaGetResponse parses an HTTP response from a ScaGetWithResponse call +func ParseScaGetResponse(rsp *http.Response) (*ScaGetResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ScaGetResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest SCAGetResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorBADREQUEST + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorUNAUTHORIZED + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorNOTFOUND + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorINTERNALSERVERERROR + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 502: + var dest ErrorINTERNALSERVERERROR + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON502 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorINTERNALSERVERERROR + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON503 = &dest + + } + + return response, nil +} + +// ParseScaListResponse parses an HTTP response from a ScaListWithResponse call +func ParseScaListResponse(rsp *http.Response) (*ScaListResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ScaListResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest SCAListResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorBADREQUEST + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorUNAUTHORIZED + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorINTERNALSERVERERROR + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 502: + var dest ErrorINTERNALSERVERERROR + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON502 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorINTERNALSERVERERROR + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON503 = &dest + + } + + return response, nil +} + // ParseSonarGateResponse parses an HTTP response from a SonarGateWithResponse call func ParseSonarGateResponse(rsp *http.Response) (*SonarGateResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) diff --git a/internal/portal/sca.go b/internal/portal/sca.go new file mode 100644 index 0000000..a4dfddb --- /dev/null +++ b/internal/portal/sca.go @@ -0,0 +1,503 @@ +package portal + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/KubeRocketCI/cli/internal/portal/restapi" + "github.com/KubeRocketCI/cli/internal/ptr" +) + +// SCAStatus is the Dep-Track binding state for a (codebase, branch) pair. +// OK means a Dep-Track project exists; NONE means the codebase has no +// Dep-Track project for the requested branch — clients emit an empty payload +// and exit 0 in that case. +type SCAStatus string + +const ( + SCAStatusOK SCAStatus = "OK" + SCAStatusNone SCAStatus = "NONE" +) + +// SCAMetrics carries the vulnerability-count rollup for a project or component. +type SCAMetrics struct { + Critical int `json:"critical"` + High int `json:"high"` + Medium int `json:"medium"` + Low int `json:"low"` + Unassigned int `json:"unassigned"` + Vulnerabilities int `json:"vulnerabilities"` + Components int `json:"components,omitempty"` + VulnerableComponents int `json:"vulnerableComponents,omitempty"` +} + +// SCAProject is one row in `krci sca list` / the `project` member of `krci sca get`. +type SCAProject struct { + UUID string `json:"uuid"` + Name string `json:"name"` + Version string `json:"version"` + Classifier string `json:"classifier,omitempty"` + Active bool `json:"active,omitempty"` + IsLatest bool `json:"isLatest,omitempty"` + LastBomImport int64 `json:"lastBomImport,omitempty"` + LastBomImportFormat string `json:"lastBomImportFormat,omitempty"` + LastVulnerabilityAnalysis int64 `json:"lastVulnerabilityAnalysis,omitempty"` + RiskScore float32 `json:"riskScore,omitempty"` + Metrics *SCAMetrics `json:"metrics,omitempty"` +} + +// SCAProjectList is the response for `krci sca list`. +type SCAProjectList struct { + Items []SCAProject `json:"items"` + TotalCount int `json:"totalCount"` +} + +// SCAProjectDetail is the response for `krci sca get `. +// When Status is NONE, Project and Metrics are nil. +type SCAProjectDetail struct { + Status SCAStatus `json:"status"` + Project *SCAProject `json:"project,omitempty"` + Metrics *SCAMetrics `json:"metrics,omitempty"` +} + +// SCAComponent is one row in `krci sca components`. +type SCAComponent struct { + UUID string `json:"uuid"` + Name string `json:"name"` + Version string `json:"version"` + LatestVersion string `json:"latestVersion,omitempty"` + Outdated bool `json:"outdated,omitempty"` + Group string `json:"group,omitempty"` + License string `json:"license,omitempty"` + IsInternal bool `json:"isInternal,omitempty"` + RiskScore float32 `json:"riskScore,omitempty"` + Metrics *SCAMetrics `json:"metrics,omitempty"` +} + +// SCAComponentList is the response for `krci sca components `. +type SCAComponentList struct { + Status SCAStatus `json:"status"` + Items []SCAComponent `json:"items"` + TotalCount int `json:"totalCount"` +} + +// SCAFindingComponent is the component side of one finding row. +type SCAFindingComponent struct { + UUID string `json:"uuid"` + Name string `json:"name"` + Version string `json:"version,omitempty"` + Group string `json:"group,omitempty"` +} + +// SCAFindingVulnerability is the vulnerability side of one finding row. +type SCAFindingVulnerability struct { + VulnID string `json:"vulnId"` + Source string `json:"source"` + Severity string `json:"severity"` + CvssV3BaseScore float32 `json:"cvssV3BaseScore,omitempty"` + CvssV2BaseScore float32 `json:"cvssV2BaseScore,omitempty"` +} + +// SCAFindingAnalysis is the analysis side of one finding row. +type SCAFindingAnalysis struct { + State string `json:"state"` + IsSuppressed bool `json:"isSuppressed"` +} + +// SCAFindingAttribution is the attribution side of one finding row. +type SCAFindingAttribution struct { + AnalyzerIdentity string `json:"analyzerIdentity,omitempty"` + AttributedOn int64 `json:"attributedOn,omitempty"` +} + +// SCAFinding is one row in `krci sca findings`. +type SCAFinding struct { + Component SCAFindingComponent `json:"component"` + Vulnerability SCAFindingVulnerability `json:"vulnerability"` + Analysis SCAFindingAnalysis `json:"analysis"` + Attribution SCAFindingAttribution `json:"attribution,omitempty"` +} + +// SCAFindingList is the response for `krci sca findings `. +type SCAFindingList struct { + Status SCAStatus `json:"status"` + Items []SCAFinding `json:"items"` + Truncated bool `json:"truncated"` +} + +// SCAListParams carries the CLI-validated inputs for `krci sca list`. +type SCAListParams struct { + Page int + PageSize int + Search string + IncludeInactive bool + IncludeChildren bool +} + +// SCAComponentsParams carries the CLI-validated inputs for `krci sca components`. +type SCAComponentsParams struct { + Codebase string + Branch string + Page int + PageSize int + OnlyOutdated bool + OnlyDirect bool +} + +// SCAFindingsParams carries the CLI-validated inputs for `krci sca findings`. +type SCAFindingsParams struct { + Codebase string + Branch string + IncludeSuppressed bool + Source string +} + +// SCAService wraps the generated restapi ScaList/ScaGet/ScaComponents/ScaFindings +// methods and returns decoupled domain structs. +type SCAService struct { + client *restapi.ClientWithResponses +} + +// NewSCAService creates an SCAService over the generated portal client. +func NewSCAService(client *restapi.ClientWithResponses) *SCAService { + return &SCAService{client: client} +} + +// checkSCAResponse extends checkResponse with 502/503 mapping used only by the +// sca endpoints — Portal translates Dep-Track-reachability failures and +// unconfigured integration into 502/503 so the CLI can distinguish those from +// a generic 500. +func checkSCAResponse(statusCode int, body []byte) error { + switch statusCode { + case http.StatusBadGateway, http.StatusServiceUnavailable: + return fmt.Errorf("%w: %s", ErrUpstreamUnavailable, truncateBody(body)) + } + + return checkResponse(statusCode, body) +} + +// scaNotFoundError wraps ErrNotFound with a user-facing message. Unlike a +// plain fmt.Errorf(...: %w, ErrNotFound), its Error() omits the sentinel +// "resource not found" suffix so the CLI emits only the rich disambiguation +// message. errors.Is(err, ErrNotFound) still matches via Unwrap. +type scaNotFoundError struct{ msg string } + +func (e *scaNotFoundError) Error() string { return e.msg } +func (e *scaNotFoundError) Unwrap() error { return ErrNotFound } + +// scaBranchNotFoundErr decodes the 404 body returned by the resolveBranch +// helper on the Portal side. The body distinguishes `codebase_not_found` vs +// `default_branch_missing`; the CLI surfaces a matching human-readable message +// wrapping ErrNotFound so callers can still match with errors.Is. +func scaBranchNotFoundErr(err error, body []byte, codebase, branch string) error { + if !errors.Is(err, ErrNotFound) { + return err + } + + // With an explicit --branch the 404 can only mean the Codebase CR itself + // is missing (Portal doesn't lookup default branch in that path). Without + // --branch, the Portal may respond 404 for either the missing CR or + // missing spec.defaultBranch. Inspect the raw body rather than the wrapped + // error text so disambiguation is robust when body is absent. + bodyLower := strings.ToLower(string(body)) + switch { + case branch != "": + return &scaNotFoundError{msg: fmt.Sprintf("codebase %s not found", codebase)} + case strings.Contains(bodyLower, "default_branch_missing"): + return &scaNotFoundError{msg: fmt.Sprintf( + "codebase %s has no spec.defaultBranch configured — pass --branch explicitly", codebase)} + default: + return &scaNotFoundError{msg: fmt.Sprintf( + "codebase %s not found — use 'krci sca list --search=%s' to find projects known to Dep-Track", + codebase, codebase)} + } +} + +func scaListParamsOnlyRoot(b bool) *restapi.ScaListParamsOnlyRoot { + v := restapi.ScaListParamsOnlyRootFalse + if b { + v = restapi.ScaListParamsOnlyRootTrue + } + return &v +} + +func scaListParamsExcludeInactive(b bool) *restapi.ScaListParamsExcludeInactive { + v := restapi.ScaListParamsExcludeInactiveFalse + if b { + v = restapi.ScaListParamsExcludeInactiveTrue + } + return &v +} + +func scaComponentsOnlyOutdated(b bool) *restapi.ScaComponentsParamsOnlyOutdated { + if !b { + return nil + } + v := restapi.ScaComponentsParamsOnlyOutdatedTrue + return &v +} + +func scaComponentsOnlyDirect(b bool) *restapi.ScaComponentsParamsOnlyDirect { + if !b { + return nil + } + v := restapi.ScaComponentsParamsOnlyDirectTrue + return &v +} + +func scaFindingsSuppressed(b bool) *restapi.ScaFindingsParamsSuppressed { + v := restapi.ScaFindingsParamsSuppressedFalse + if b { + v = restapi.ScaFindingsParamsSuppressedTrue + } + return &v +} + +// List returns the paginated Dep-Track project listing. +func (s *SCAService) List(ctx context.Context, params SCAListParams) (*SCAProjectList, error) { + p := &restapi.ScaListParams{} + if params.Page > 0 { + p.PageNumber = ptr.To(params.Page) + } + if params.PageSize > 0 { + p.PageSize = ptr.To(params.PageSize) + } + if params.Search != "" { + p.SearchTerm = ptr.To(params.Search) + } + // Defaults (match Portal UI): exclude inactive, only root. + p.ExcludeInactive = scaListParamsExcludeInactive(!params.IncludeInactive) + p.OnlyRoot = scaListParamsOnlyRoot(!params.IncludeChildren) + + resp, err := s.client.ScaListWithResponse(ctx, p) + if err != nil { + return nil, fmt.Errorf("calling sca list: %w", err) + } + + if err := checkSCAResponse(resp.StatusCode(), resp.Body); err != nil { + return nil, err + } + + if resp.JSON200 == nil { + return nil, fmt.Errorf("portal returned empty sca list response") + } + + return mapSCAProjectList(resp.JSON200), nil +} + +// Get returns the Dep-Track project overview for a codebase/branch pair. +// When no project is bound for the pair, Status is SCAStatusNone and Project +// / Metrics are nil. +func (s *SCAService) Get(ctx context.Context, codebase, branch string) (*SCAProjectDetail, error) { + p := &restapi.ScaGetParams{Codebase: codebase} + if branch != "" { + p.Branch = ptr.To(branch) + } + + resp, err := s.client.ScaGetWithResponse(ctx, p) + if err != nil { + return nil, fmt.Errorf("calling sca get: %w", err) + } + + if err := checkSCAResponse(resp.StatusCode(), resp.Body); err != nil { + return nil, scaBranchNotFoundErr(err, resp.Body, codebase, branch) + } + + if resp.JSON200 == nil { + return nil, fmt.Errorf("portal returned empty sca get response") + } + + return mapSCAProjectDetail(resp.JSON200), nil +} + +// Components returns the paginated dependency list for a codebase/branch pair. +func (s *SCAService) Components(ctx context.Context, params SCAComponentsParams) (*SCAComponentList, error) { + p := &restapi.ScaComponentsParams{Codebase: params.Codebase} + if params.Branch != "" { + p.Branch = ptr.To(params.Branch) + } + if params.Page > 0 { + p.PageNumber = ptr.To(params.Page) + } + if params.PageSize > 0 { + p.PageSize = ptr.To(params.PageSize) + } + p.OnlyOutdated = scaComponentsOnlyOutdated(params.OnlyOutdated) + p.OnlyDirect = scaComponentsOnlyDirect(params.OnlyDirect) + + resp, err := s.client.ScaComponentsWithResponse(ctx, p) + if err != nil { + return nil, fmt.Errorf("calling sca components: %w", err) + } + + if err := checkSCAResponse(resp.StatusCode(), resp.Body); err != nil { + return nil, scaBranchNotFoundErr(err, resp.Body, params.Codebase, params.Branch) + } + + if resp.JSON200 == nil { + return nil, fmt.Errorf("portal returned empty sca components response") + } + + return mapSCAComponentList(resp.JSON200), nil +} + +// Findings returns the vulnerability findings for a codebase/branch pair. +// The Portal caps the server-side result at 1000 rows; when exceeded, +// Truncated is true. +func (s *SCAService) Findings(ctx context.Context, params SCAFindingsParams) (*SCAFindingList, error) { + p := &restapi.ScaFindingsParams{Codebase: params.Codebase} + if params.Branch != "" { + p.Branch = ptr.To(params.Branch) + } + p.Suppressed = scaFindingsSuppressed(params.IncludeSuppressed) + if params.Source != "" { + p.Source = ptr.To(params.Source) + } + + resp, err := s.client.ScaFindingsWithResponse(ctx, p) + if err != nil { + return nil, fmt.Errorf("calling sca findings: %w", err) + } + + if err := checkSCAResponse(resp.StatusCode(), resp.Body); err != nil { + return nil, scaBranchNotFoundErr(err, resp.Body, params.Codebase, params.Branch) + } + + if resp.JSON200 == nil { + return nil, fmt.Errorf("portal returned empty sca findings response") + } + + return mapSCAFindingList(resp.JSON200), nil +} + +// --------------------------------------------------------------------------- +// Mappers — isolate generated types behind the service boundary. +// --------------------------------------------------------------------------- + +func mapSCAProjectList(src *restapi.SCAListResponse) *SCAProjectList { + out := &SCAProjectList{ + Items: make([]SCAProject, 0, len(src.Items)), + TotalCount: src.TotalCount, + } + for i := range src.Items { + out.Items = append(out.Items, mapSCAProject(&src.Items[i])) + } + return out +} + +func mapSCAProject(src *restapi.SCAProject) SCAProject { + return SCAProject{ + UUID: src.Uuid, + Name: src.Name, + Version: src.Version, + Classifier: ptr.Deref(src.Classifier, ""), + Active: ptr.Deref(src.Active, false), + IsLatest: ptr.Deref(src.IsLatest, false), + LastBomImport: ptr.Deref(src.LastBomImport, 0), + LastBomImportFormat: ptr.Deref(src.LastBomImportFormat, ""), + LastVulnerabilityAnalysis: ptr.Deref(src.LastVulnerabilityAnalysis, 0), + RiskScore: ptr.Deref(src.RiskScore, 0), + Metrics: mapSCAMetrics(src.Metrics), + } +} + +func mapSCAProjectDetail(src *restapi.SCAGetResponse) *SCAProjectDetail { + out := &SCAProjectDetail{ + Status: SCAStatus(src.Status), + } + if src.Project != nil { + p := mapSCAProject(src.Project) + out.Project = &p + } + if src.Metrics != nil { + out.Metrics = mapSCAMetrics(src.Metrics) + } + return out +} + +func mapSCAMetrics(src *restapi.SCAMetrics) *SCAMetrics { + if src == nil { + return nil + } + return &SCAMetrics{ + Critical: ptr.Deref(src.Critical, 0), + High: ptr.Deref(src.High, 0), + Medium: ptr.Deref(src.Medium, 0), + Low: ptr.Deref(src.Low, 0), + Unassigned: ptr.Deref(src.Unassigned, 0), + Vulnerabilities: ptr.Deref(src.Vulnerabilities, 0), + Components: ptr.Deref(src.Components, 0), + VulnerableComponents: ptr.Deref(src.VulnerableComponents, 0), + } +} + +func mapSCAComponentList(src *restapi.SCAComponentsResponse) *SCAComponentList { + out := &SCAComponentList{ + Status: SCAStatus(src.Status), + Items: make([]SCAComponent, 0, len(src.Items)), + TotalCount: src.TotalCount, + } + for i := range src.Items { + out.Items = append(out.Items, mapSCAComponent(&src.Items[i])) + } + return out +} + +func mapSCAComponent(src *restapi.SCAComponent) SCAComponent { + return SCAComponent{ + UUID: src.Uuid, + Name: src.Name, + Version: src.Version, + LatestVersion: ptr.Deref(src.LatestVersion, ""), + Outdated: ptr.Deref(src.Outdated, false), + Group: ptr.Deref(src.Group, ""), + License: ptr.Deref(src.License, ""), + IsInternal: ptr.Deref(src.IsInternal, false), + RiskScore: ptr.Deref(src.RiskScore, 0), + Metrics: mapSCAMetrics(src.Metrics), + } +} + +func mapSCAFindingList(src *restapi.SCAFindingsResponse) *SCAFindingList { + out := &SCAFindingList{ + Status: SCAStatus(src.Status), + Items: make([]SCAFinding, 0, len(src.Items)), + Truncated: src.Truncated, + } + for i := range src.Items { + out.Items = append(out.Items, mapSCAFinding(&src.Items[i])) + } + return out +} + +func mapSCAFinding(src *restapi.SCAFinding) SCAFinding { + f := SCAFinding{ + Component: SCAFindingComponent{ + UUID: src.Component.Uuid, + Name: src.Component.Name, + Version: ptr.Deref(src.Component.Version, ""), + Group: ptr.Deref(src.Component.Group, ""), + }, + Vulnerability: SCAFindingVulnerability{ + VulnID: src.Vulnerability.VulnId, + Source: src.Vulnerability.Source, + Severity: string(src.Vulnerability.Severity), + CvssV3BaseScore: ptr.Deref(src.Vulnerability.CvssV3BaseScore, 0), + CvssV2BaseScore: ptr.Deref(src.Vulnerability.CvssV2BaseScore, 0), + }, + Analysis: SCAFindingAnalysis{ + State: src.Analysis.State, + IsSuppressed: src.Analysis.IsSuppressed, + }, + } + if src.Attribution != nil { + f.Attribution = SCAFindingAttribution{ + AnalyzerIdentity: ptr.Deref(src.Attribution.AnalyzerIdentity, ""), + AttributedOn: ptr.Deref(src.Attribution.AttributedOn, 0), + } + } + return f +} diff --git a/internal/portal/sca_test.go b/internal/portal/sca_test.go new file mode 100644 index 0000000..d65c9db --- /dev/null +++ b/internal/portal/sca_test.go @@ -0,0 +1,467 @@ +package portal + +import ( + "context" + "errors" + "net/http" + "strings" + "testing" +) + +const ( + testCodebase = "my-service" + testSCABranch = "main" + qTrue = "true" + qFalse = "false" +) + +func TestSCAService_List_Success(t *testing.T) { + t.Parallel() + + handler := func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/rest/v1/sca/list" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + q := r.URL.Query() + if q.Get("pageNumber") != "2" || q.Get("pageSize") != "25" || q.Get("searchTerm") != "pay" { + t.Fatalf("unexpected query: %s", r.URL.RawQuery) + } + // Defaults forwarded: onlyRoot=true, excludeInactive=true. + if q.Get("onlyRoot") != qTrue || q.Get("excludeInactive") != qTrue { + t.Fatalf("expected default onlyRoot=true, excludeInactive=true, got %s", r.URL.RawQuery) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{ + "items": [ + {"uuid":"u1","name":"pay-api","version":"main","classifier":"APPLICATION","active":true,"riskScore":1.5, + "metrics": {"critical": 1, "high": 0, "medium": 2, "low": 3, "unassigned": 0, "vulnerabilities": 6}} + ], + "totalCount": 42 + }`)) + } + + client, closer := newTestClient(t, handler) + defer closer() + + got, err := NewSCAService(client).List(context.Background(), SCAListParams{ + Page: 2, PageSize: 25, Search: "pay", + }) + if err != nil { + t.Fatalf("List returned error: %v", err) + } + if got.TotalCount != 42 || len(got.Items) != 1 || got.Items[0].Name != "pay-api" { + t.Errorf("unexpected: %+v", got) + } + if got.Items[0].Metrics == nil || got.Items[0].Metrics.Critical != 1 || got.Items[0].Metrics.Vulnerabilities != 6 { + t.Errorf("unexpected metrics: %+v", got.Items[0].Metrics) + } +} + +func TestSCAService_List_IncludesInactiveAndChildrenFlipsFlags(t *testing.T) { + t.Parallel() + + handler := func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if q.Get("onlyRoot") != qFalse || q.Get("excludeInactive") != qFalse { + t.Fatalf("expected onlyRoot=false, excludeInactive=false, got %s", r.URL.RawQuery) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"items": [], "totalCount": 0}`)) + } + + client, closer := newTestClient(t, handler) + defer closer() + + _, err := NewSCAService(client).List(context.Background(), SCAListParams{ + IncludeInactive: true, IncludeChildren: true, + }) + if err != nil { + t.Fatalf("List error: %v", err) + } +} + +func TestSCAService_List_EmptyResult_InitialisesItemsSlice(t *testing.T) { + t.Parallel() + + handler := func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"items": [], "totalCount": 0}`)) + } + + client, closer := newTestClient(t, handler) + defer closer() + + got, err := NewSCAService(client).List(context.Background(), SCAListParams{}) + if err != nil { + t.Fatalf("List error: %v", err) + } + if got.Items == nil { + t.Fatalf("Items must be non-nil so JSON emits [] not null") + } +} + +func TestSCAService_List_401(t *testing.T) { + t.Parallel() + + handler := func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + } + client, closer := newTestClient(t, handler) + defer closer() + + _, err := NewSCAService(client).List(context.Background(), SCAListParams{}) + if !errors.Is(err, ErrUnauthorized) { + t.Fatalf("want ErrUnauthorized, got %v", err) + } +} + +func TestSCAService_List_502_MapsToUpstreamUnavailable(t *testing.T) { + t.Parallel() + + handler := func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadGateway) + w.Write([]byte(`{"error":{"code":"BAD_GATEWAY","message":"DT down"}}`)) + } + client, closer := newTestClient(t, handler) + defer closer() + + _, err := NewSCAService(client).List(context.Background(), SCAListParams{}) + if !errors.Is(err, ErrUpstreamUnavailable) { + t.Fatalf("want ErrUpstreamUnavailable, got %v", err) + } +} + +func TestSCAService_List_503_MapsToUpstreamUnavailable(t *testing.T) { + t.Parallel() + + handler := func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + w.Write([]byte(`{"error":{"code":"SERVICE_UNAVAILABLE","message":"not configured"}}`)) + } + client, closer := newTestClient(t, handler) + defer closer() + + _, err := NewSCAService(client).List(context.Background(), SCAListParams{}) + if !errors.Is(err, ErrUpstreamUnavailable) { + t.Fatalf("want ErrUpstreamUnavailable, got %v", err) + } +} + +func TestSCAService_Get_Success_ExplicitBranch(t *testing.T) { + t.Parallel() + + handler := func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/rest/v1/sca/get" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + q := r.URL.Query() + if q.Get("codebase") != testCodebase || q.Get("branch") != testSCABranch { + t.Fatalf("unexpected query: %s", r.URL.RawQuery) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{ + "status": "OK", + "project": {"uuid":"u1","name":"my-service","version":"main","classifier":"APPLICATION","active":true,"riskScore":12.5}, + "metrics": {"critical": 3, "high": 5, "medium": 0, "low": 0, "unassigned": 0, "vulnerabilities": 8, "components": 100} + }`)) + } + + client, closer := newTestClient(t, handler) + defer closer() + + got, err := NewSCAService(client).Get(context.Background(), testCodebase, testSCABranch) + if err != nil { + t.Fatalf("Get error: %v", err) + } + if got.Status != SCAStatusOK || got.Project == nil || got.Metrics == nil { + t.Fatalf("unexpected: %+v", got) + } + if got.Metrics.Critical != 3 || got.Metrics.Components != 100 { + t.Errorf("unexpected metrics: %+v", got.Metrics) + } +} + +func TestSCAService_Get_OmitsBranchWhenEmpty(t *testing.T) { + t.Parallel() + + handler := func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("branch"); got != "" { + t.Fatalf("branch should not be set, got %q", got) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":"NONE"}`)) + } + + client, closer := newTestClient(t, handler) + defer closer() + + got, err := NewSCAService(client).Get(context.Background(), testCodebase, "") + if err != nil { + t.Fatalf("Get error: %v", err) + } + if got.Status != SCAStatusNone { + t.Errorf("want NONE, got %s", got.Status) + } + if got.Project != nil || got.Metrics != nil { + t.Errorf("NONE payload must leave project/metrics nil, got %+v", got) + } +} + +func TestSCAService_Get_404_CodebaseNotFound_WithExplicitBranch(t *testing.T) { + t.Parallel() + + handler := func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"reason":"codebase_not_found","message":"Codebase 'nope' not found"}`)) + } + client, closer := newTestClient(t, handler) + defer closer() + + _, err := NewSCAService(client).Get(context.Background(), "nope", "main") + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, ErrNotFound) { + t.Errorf("want wrap of ErrNotFound, got %v", err) + } + if !strings.Contains(err.Error(), "codebase nope not found") { + t.Errorf("unexpected message: %v", err) + } +} + +func TestSCAService_Get_404_DefaultBranchMissing_DistinguishedInMessage(t *testing.T) { + t.Parallel() + + handler := func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"reason":"default_branch_missing","message":"Codebase 'foo' has no spec.defaultBranch configured"}`)) + } + client, closer := newTestClient(t, handler) + defer closer() + + _, err := NewSCAService(client).Get(context.Background(), "foo", "") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "has no spec.defaultBranch") { + t.Errorf("expected default-branch-missing hint, got %v", err) + } +} + +func TestSCAService_Get_404_CodebaseNotFound_NoBranch_GenericHint(t *testing.T) { + t.Parallel() + + handler := func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"reason":"codebase_not_found","message":"Codebase 'foo' not found"}`)) + } + client, closer := newTestClient(t, handler) + defer closer() + + _, err := NewSCAService(client).Get(context.Background(), "foo", "") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "use 'krci sca list --search=foo'") { + t.Errorf("expected discover hint, got %v", err) + } +} + +func TestSCAService_Components_Success_ForwardsFilters(t *testing.T) { + t.Parallel() + + handler := func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if q.Get("codebase") != testCodebase || q.Get("branch") != testSCABranch { + t.Fatalf("unexpected scope: %s", r.URL.RawQuery) + } + if q.Get("onlyOutdated") != qTrue || q.Get("onlyDirect") != qTrue { + t.Fatalf("expected passthrough of filters: %s", r.URL.RawQuery) + } + if q.Get("pageNumber") != "1" || q.Get("pageSize") != "50" { + t.Fatalf("expected pagination: %s", r.URL.RawQuery) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{ + "status": "OK", + "items": [ + {"uuid":"c1","name":"log4j-core","version":"2.11.2","latestVersion":"2.24.0","outdated":true, + "metrics": {"critical": 1, "high": 0, "medium": 0, "low": 0, "unassigned": 0, "vulnerabilities": 1}} + ], + "totalCount": 120 + }`)) + } + + client, closer := newTestClient(t, handler) + defer closer() + + got, err := NewSCAService(client).Components(context.Background(), SCAComponentsParams{ + Codebase: testCodebase, Branch: testSCABranch, Page: 1, PageSize: 50, + OnlyOutdated: true, OnlyDirect: true, + }) + if err != nil { + t.Fatalf("Components error: %v", err) + } + if got.Status != SCAStatusOK || got.TotalCount != 120 || !got.Items[0].Outdated { + t.Errorf("unexpected: %+v", got) + } +} + +func TestSCAService_Components_OmitsOptionalFiltersWhenFalse(t *testing.T) { + t.Parallel() + + handler := func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if q.Get("onlyOutdated") != "" || q.Get("onlyDirect") != "" { + t.Fatalf("filters should be omitted when false, got %s", r.URL.RawQuery) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":"OK","items":[],"totalCount":0}`)) + } + + client, closer := newTestClient(t, handler) + defer closer() + + _, err := NewSCAService(client).Components(context.Background(), SCAComponentsParams{ + Codebase: testCodebase, + }) + if err != nil { + t.Fatalf("Components error: %v", err) + } +} + +func TestSCAService_Components_NONE_InitialisesItemsSlice(t *testing.T) { + t.Parallel() + + handler := func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":"NONE","items":[],"totalCount":0}`)) + } + + client, closer := newTestClient(t, handler) + defer closer() + + got, err := NewSCAService(client).Components(context.Background(), SCAComponentsParams{ + Codebase: testCodebase, + }) + if err != nil { + t.Fatalf("Components error: %v", err) + } + if got.Status != SCAStatusNone || got.Items == nil { + t.Errorf("want NONE with non-nil Items, got %+v", got) + } +} + +func TestSCAService_Findings_Success_PassesSourceAndSuppressed(t *testing.T) { + t.Parallel() + + handler := func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if q.Get("suppressed") != qTrue || q.Get("source") != "NVD" { + t.Fatalf("expected suppressed=true, source=NVD, got %s", r.URL.RawQuery) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{ + "status": "OK", + "items": [{ + "component": {"uuid":"c1","name":"log4j-core","version":"2.11.2"}, + "vulnerability": {"vulnId":"CVE-2021-44228","source":"NVD","severity":"CRITICAL","cvssV3BaseScore":10.0}, + "analysis": {"state":"NOT_SET","isSuppressed":false}, + "attribution": {"analyzerIdentity":"OSSINDEX_ANALYZER","attributedOn":1713456000000} + }], + "truncated": false + }`)) + } + + client, closer := newTestClient(t, handler) + defer closer() + + got, err := NewSCAService(client).Findings(context.Background(), SCAFindingsParams{ + Codebase: testCodebase, IncludeSuppressed: true, Source: "NVD", + }) + if err != nil { + t.Fatalf("Findings error: %v", err) + } + if got.Status != SCAStatusOK || len(got.Items) != 1 || got.Items[0].Vulnerability.VulnID != "CVE-2021-44228" { + t.Errorf("unexpected: %+v", got) + } + if got.Truncated { + t.Error("Truncated must be false") + } +} + +func TestSCAService_Findings_DefaultSuppressedIsFalse(t *testing.T) { + t.Parallel() + + handler := func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("suppressed") != qFalse { + t.Fatalf("expected suppressed=false by default, got %s", r.URL.RawQuery) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":"OK","items":[],"truncated":false}`)) + } + + client, closer := newTestClient(t, handler) + defer closer() + + _, err := NewSCAService(client).Findings(context.Background(), SCAFindingsParams{Codebase: testCodebase}) + if err != nil { + t.Fatalf("Findings error: %v", err) + } +} + +func TestSCAService_Findings_TruncatedFlagPropagates(t *testing.T) { + t.Parallel() + + handler := func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":"OK","items":[],"truncated":true}`)) + } + + client, closer := newTestClient(t, handler) + defer closer() + + got, err := NewSCAService(client).Findings(context.Background(), SCAFindingsParams{Codebase: testCodebase}) + if err != nil { + t.Fatalf("Findings error: %v", err) + } + if !got.Truncated { + t.Error("Truncated must be true") + } +} + +func TestSCAService_Findings_MalformedBody_Returns5xx(t *testing.T) { + t.Parallel() + + handler := func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error":{"code":"INTERNAL_SERVER_ERROR","message":"oops"}}`)) + } + + client, closer := newTestClient(t, handler) + defer closer() + + _, err := NewSCAService(client).Findings(context.Background(), SCAFindingsParams{Codebase: testCodebase}) + if err == nil || !strings.Contains(err.Error(), "HTTP 500") { + t.Errorf("expected HTTP 500 error, got %v", err) + } +} + +func TestSCAService_Get_404_DefaultBranchMissing_CaseInsensitive(t *testing.T) { + t.Parallel() + + handler := func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"reason":"DEFAULT_BRANCH_MISSING","message":"upper-case sentinel"}`)) + } + client, closer := newTestClient(t, handler) + defer closer() + + _, err := NewSCAService(client).Get(context.Background(), "foo", "") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "has no spec.defaultBranch") { + t.Errorf("expected default-branch hint regardless of case, got %v", err) + } +} diff --git a/internal/portal/sonar.go b/internal/portal/sonar.go index 567e375..22e07d4 100644 --- a/internal/portal/sonar.go +++ b/internal/portal/sonar.go @@ -272,9 +272,9 @@ func (s *SonarService) Issues(ctx context.Context, params SonarIssuesParams) (*S p.S = ptr.To(params.Sort) } if params.Asc != nil { - v := restapi.SonarIssuesParamsAscFalse + v := restapi.False if *params.Asc { - v = restapi.SonarIssuesParamsAscTrue + v = restapi.True } p.Asc = &v } diff --git a/internal/portal/sonar_test.go b/internal/portal/sonar_test.go index 716665d..0c6da99 100644 --- a/internal/portal/sonar_test.go +++ b/internal/portal/sonar_test.go @@ -1015,7 +1015,7 @@ func TestMapSonarIssueList_TagsNil(t *testing.T) { { Key: "I1", Rule: "rule1", - Severity: restapi.BLOCKER, + Severity: restapi.SonarIssueSeverityBLOCKER, Type: restapi.BUG, Status: restapi.OPEN, Component: "comp", @@ -1044,7 +1044,7 @@ func TestMapSonarIssueList_TagsNonNil(t *testing.T) { { Key: "I2", Rule: "rule2", - Severity: restapi.CRITICAL, + Severity: restapi.SonarIssueSeverityCRITICAL, Type: restapi.VULNERABILITY, Status: restapi.OPEN, Component: "comp", diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 7158765..0495468 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -10,6 +10,7 @@ import ( "github.com/KubeRocketCI/cli/pkg/cmd/deployment" "github.com/KubeRocketCI/cli/pkg/cmd/pipelinerun" "github.com/KubeRocketCI/cli/pkg/cmd/project" + "github.com/KubeRocketCI/cli/pkg/cmd/sca" "github.com/KubeRocketCI/cli/pkg/cmd/sonar" "github.com/KubeRocketCI/cli/pkg/cmd/version" ) @@ -43,6 +44,7 @@ func NewCmdRoot(f *cmdutil.Factory, v, commit, date string) *cobra.Command { project.NewCmdProject(f), deployment.NewCmdDeployment(f), pipelinerun.NewCmdPipelineRun(f), + sca.NewCmdSca(f), sonar.NewCmdSonar(f), version.NewCmdVersion(f.IOStreams, v, commit, date), ) diff --git a/pkg/cmd/sca/components/components.go b/pkg/cmd/sca/components/components.go new file mode 100644 index 0000000..b96ec73 --- /dev/null +++ b/pkg/cmd/sca/components/components.go @@ -0,0 +1,202 @@ +// Package components implements the "krci sca components" command. +package components + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/spf13/cobra" + + "github.com/KubeRocketCI/cli/internal/cmdutil" + "github.com/KubeRocketCI/cli/internal/iostreams" + "github.com/KubeRocketCI/cli/internal/portal" + "github.com/KubeRocketCI/cli/internal/portal/restapi" + scainternal "github.com/KubeRocketCI/cli/pkg/cmd/sca/internal" +) + +const defaultComponentsPageSize = 50 + +// ComponentsOptions holds all inputs for `krci sca components `. +type ComponentsOptions struct { + IO *iostreams.IOStreams + RestClient func() (*restapi.ClientWithResponses, error) + Codebase string + Branch string + Severity []string + OnlyOutdated bool + OnlyDirect bool + Page int + PageSize int + OutputFormat string +} + +// NewCmdComponents returns the "sca components" cobra.Command. +func NewCmdComponents(f *cmdutil.Factory, runF func(*ComponentsOptions) error) *cobra.Command { + opts := &ComponentsOptions{ + IO: f.IOStreams, + RestClient: f.RestClient, + } + + cmd := &cobra.Command{ + Use: "components ", + Short: "List dependencies (components) for an SCA project", + Args: cmdutil.ExactArgs(1, "a KubeRocketCI codebase name", + "to see available projects: krci sca list"), + Example: ` # Default branch, first 50 dependencies + krci sca components payments-api + + # Outdated, direct dependencies only + krci sca components payments-api --only-outdated --only-direct + + # Dependencies with at least one HIGH (or CRITICAL) finding, explicit branch + krci sca components payments-api --branch=release/1.0 --severity=high + + # Scripting — package names for the default branch + krci sca components payments-api -o json | jq -r '.data.items[].name'`, + RunE: func(cmd *cobra.Command, args []string) error { + opts.Codebase = args[0] + + if err := scainternal.ValidateCodebaseCommand( + cmd, opts.OutputFormat, opts.Codebase, opts.Branch, + ); err != nil { + return err + } + + if err := scainternal.ValidatePageBounds(opts.Page, opts.PageSize); err != nil { + return err + } + + if _, err := scainternal.ValidateSeverityCSV(opts.Severity); err != nil { + return err + } + + if runF != nil { + return runF(opts) + } + + return componentsRun(cmd.Context(), opts) + }, + } + + cmd.Flags().StringVar(&opts.Branch, "branch", "", scainternal.BranchFlagUsage) + cmd.Flags().StringSliceVar(&opts.Severity, "severity", nil, + scainternal.SeverityFlagUsage+ + " Applied client-side to the fetched page only; "+ + "increase --page-size to examine more components.") + cmd.Flags().BoolVar(&opts.OnlyOutdated, "only-outdated", false, "Only components marked outdated by Dependency-Track") + cmd.Flags().BoolVar(&opts.OnlyDirect, "only-direct", false, "Only direct (non-transitive) dependencies") + cmd.Flags().IntVar(&opts.Page, "page", 1, "Page index (1-based)") + cmd.Flags().IntVar(&opts.PageSize, "page-size", defaultComponentsPageSize, "Page size (max 500)") + cmd.Flags().StringVarP(&opts.OutputFormat, "output", "o", "", + "Output format: table, json (default: table)") + + return cmd +} + +func componentsRun(ctx context.Context, opts *ComponentsOptions) error { + client, err := opts.RestClient() + if err != nil { + return scainternal.HandleError(opts.IO, opts.OutputFormat, err) + } + + result, err := portal.NewSCAService(client).Components(ctx, portal.SCAComponentsParams{ + Codebase: opts.Codebase, + Branch: opts.Branch, + Page: opts.Page, + PageSize: opts.PageSize, + OnlyOutdated: opts.OnlyOutdated, + OnlyDirect: opts.OnlyDirect, + }) + if err != nil { + return scainternal.HandleError(opts.IO, opts.OutputFormat, err) + } + + if inclusive := scainternal.ExpandSeverityFlag(opts.Severity); len(inclusive) > 0 { + filtered := applySeverityFilter(result.Items, inclusive) + result.Items = filtered + // The server returns a TotalCount across the unfiltered page; once we + // narrow client-side, both the JSON envelope and the table footer must + // reflect the visible row count or the "page X of Y" line lies. + result.TotalCount = len(filtered) + } + + return scainternal.Render(opts.IO, opts.OutputFormat, result, func(w io.Writer, isTTY bool) error { + return renderTable(w, isTTY, opts.Codebase, opts.Page, opts.PageSize, result) + }) +} + +// applySeverityFilter keeps only components whose metrics contain at least +// one vulnerability in the allowed severity set. Empty allowed means no +// filter. Client-side because Dep-Track's /component/project endpoint does +// not filter by severity. +func applySeverityFilter(items []portal.SCAComponent, allowed []string) []portal.SCAComponent { + if len(allowed) == 0 { + return items + } + out := make([]portal.SCAComponent, 0, len(items)) + for _, c := range items { + if c.Metrics == nil { + continue + } + if scainternal.ComponentMatchesSeverity(func(sev string) int { + return metricsCountFor(c.Metrics, sev) + }, allowed) { + out = append(out, c) + } + } + return out +} + +func metricsCountFor(m *portal.SCAMetrics, severity string) int { + switch strings.ToUpper(severity) { + case "CRITICAL": + return m.Critical + case "HIGH": + return m.High + case "MEDIUM": + return m.Medium + case "LOW": + return m.Low + case "INFO", "UNASSIGNED": + // Dep-Track folds these together in its component metrics. + return m.Unassigned + } + return 0 +} + +func renderTable( + w io.Writer, isTTY bool, codebase string, page, pageSize int, result *portal.SCAComponentList, +) error { + if result.Status == portal.SCAStatusNone { + _, err := fmt.Fprintf(w, "status: NONE — no SCA scanner bound for %s\n", codebase) + return err + } + + headers := []string{"COMPONENT", "CURRENT", "LATEST", "OUTDATED", "LICENSE", "RISK", "VULNS (C/H/M/L)"} + rows := make([][]string, 0, len(result.Items)) + + for _, c := range result.Items { + rows = append(rows, []string{ + c.Name, + c.Version, + scainternal.OrDash(c.LatestVersion), + scainternal.BoolYesNo(c.Outdated), + scainternal.OrDash(c.License), + scainternal.FormatRisk(c.RiskScore), + scainternal.FormatVulnCounts(c.Metrics, isTTY), + }) + } + + if err := scainternal.PrintTable(w, isTTY, headers, rows); err != nil { + return err + } + + if _, err := fmt.Fprintln(w); err != nil { + return err + } + + _, err := fmt.Fprintln(w, scainternal.PageFooter("component", result.TotalCount, page, pageSize)) + return err +} diff --git a/pkg/cmd/sca/components/components_test.go b/pkg/cmd/sca/components/components_test.go new file mode 100644 index 0000000..9456221 --- /dev/null +++ b/pkg/cmd/sca/components/components_test.go @@ -0,0 +1,140 @@ +package components + +import ( + "bytes" + "strings" + "testing" + + "github.com/KubeRocketCI/cli/internal/portal" + "github.com/KubeRocketCI/cli/pkg/cmd/sca/internal/scatestutil" +) + +func TestComponents_RejectsInvalidSeverity(t *testing.T) { + t.Parallel() + + cmd := NewCmdComponents(scatestutil.NewFactory(), nil) + cmd.SetArgs([]string{"svc", "--severity", "garbage"}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "invalid --severity") { + t.Errorf("expected severity error, got %v", err) + } +} + +func TestComponents_RunFCapturesFlags(t *testing.T) { + t.Parallel() + + var captured *ComponentsOptions + cmd := NewCmdComponents(scatestutil.NewFactory(), func(o *ComponentsOptions) error { + captured = o + return nil + }) + cmd.SetArgs([]string{"svc", "--severity", "high", "--only-outdated", "--branch", "main"}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + if captured == nil { + t.Fatal("runF not invoked") + } + if !captured.OnlyOutdated || captured.Branch != "main" || len(captured.Severity) != 1 { + t.Errorf("flag capture: %+v", captured) + } +} + +func TestComponents_PageBounds(t *testing.T) { + t.Parallel() + + for _, args := range [][]string{ + {"svc", "--page", "0"}, + {"svc", "--page-size", "0"}, + {"svc", "--page-size", "1000"}, + } { + cmd := NewCmdComponents(scatestutil.NewFactory(), nil) + cmd.SetArgs(args) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + if err := cmd.Execute(); err == nil { + t.Errorf("args %v: expected error", args) + } + } +} + +func TestComponents_RejectsPRFlag(t *testing.T) { + t.Parallel() + + cmd := NewCmdComponents(scatestutil.NewFactory(), nil) + cmd.SetArgs([]string{"svc", "--pr", "42"}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "unknown flag") { + t.Errorf("expected unknown flag error, got %v", err) + } +} + +func TestApplySeverityFilter(t *testing.T) { + t.Parallel() + + items := []portal.SCAComponent{ + {Name: "a", Metrics: &portal.SCAMetrics{Critical: 1}}, + {Name: "b", Metrics: &portal.SCAMetrics{Medium: 2}}, + {Name: "c", Metrics: &portal.SCAMetrics{Low: 1}}, + {Name: "d", Metrics: nil}, + } + + // No filter → passthrough (all four, including nil-metrics component). + got := applySeverityFilter(items, nil) + if len(got) != 4 { + t.Errorf("nil filter must return all items, got %d", len(got)) + } + + // "HIGH" → only components with CRITICAL or HIGH metrics. + got = applySeverityFilter(items, []string{"CRITICAL", "HIGH"}) + if len(got) != 1 || got[0].Name != "a" { + t.Errorf("filter got %v; want ['a']", got) + } + + // "MEDIUM" → CRITICAL + HIGH + MEDIUM-bearing rows. + got = applySeverityFilter(items, []string{"CRITICAL", "HIGH", "MEDIUM"}) + if len(got) != 2 { + t.Errorf("filter got %v; want 2 rows", got) + } +} + +func TestMetricsCountFor(t *testing.T) { + t.Parallel() + m := &portal.SCAMetrics{Critical: 1, High: 2, Medium: 3, Low: 4, Unassigned: 5} + + cases := map[string]int{ + "CRITICAL": 1, + "HIGH": 2, + "MEDIUM": 3, + "LOW": 4, + "INFO": 5, + "UNASSIGNED": 5, + "OTHER": 0, + } + for sev, want := range cases { + if got := metricsCountFor(m, sev); got != want { + t.Errorf("metricsCountFor(%s) = %d; want %d", sev, got, want) + } + } +} + +func TestRenderTable_NONE(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + if err := renderTable(&buf, false, "svc", 1, 50, + &portal.SCAComponentList{Status: portal.SCAStatusNone}); err != nil { + t.Fatalf("renderTable: %v", err) + } + if !strings.Contains(buf.String(), "status: NONE") { + t.Errorf("want NONE banner, got %q", buf.String()) + } +} diff --git a/pkg/cmd/sca/findings/findings.go b/pkg/cmd/sca/findings/findings.go new file mode 100644 index 0000000..9b14239 --- /dev/null +++ b/pkg/cmd/sca/findings/findings.go @@ -0,0 +1,194 @@ +// Package findings implements the "krci sca findings" command. +package findings + +import ( + "context" + "fmt" + "io" + + "github.com/spf13/cobra" + + "github.com/KubeRocketCI/cli/internal/cmdutil" + "github.com/KubeRocketCI/cli/internal/iostreams" + "github.com/KubeRocketCI/cli/internal/output" + "github.com/KubeRocketCI/cli/internal/portal" + "github.com/KubeRocketCI/cli/internal/portal/restapi" + scainternal "github.com/KubeRocketCI/cli/pkg/cmd/sca/internal" +) + +const ( + maxIDColumnWidth = 40 + maxCompColumnWidth = 40 +) + +// FindingsOptions holds all inputs for `krci sca findings `. +type FindingsOptions struct { + IO *iostreams.IOStreams + RestClient func() (*restapi.ClientWithResponses, error) + Codebase string + Branch string + Severity []string + IncludeSuppressed bool + Source string + OutputFormat string +} + +// NewCmdFindings returns the "sca findings" cobra.Command. +func NewCmdFindings(f *cmdutil.Factory, runF func(*FindingsOptions) error) *cobra.Command { + opts := &FindingsOptions{ + IO: f.IOStreams, + RestClient: f.RestClient, + } + + cmd := &cobra.Command{ + Use: "findings ", + Short: "List Dep-Track vulnerability findings for a codebase", + Args: cmdutil.ExactArgs(1, "a KubeRocketCI codebase name", + "to see available projects: krci sca list"), + Example: ` # All unsuppressed findings, default branch + krci sca findings payments-api + + # Critical only + krci sca findings payments-api --severity=critical + + # Suppressed findings included, filter by vuln source + krci sca findings payments-api --include-suppressed --source=NVD + + # Scripting — CVE IDs for the default branch + krci sca findings payments-api -o json | jq -r '.data.items[].vulnerability.vulnId'`, + RunE: func(cmd *cobra.Command, args []string) error { + opts.Codebase = args[0] + + if err := scainternal.ValidateCodebaseCommand( + cmd, opts.OutputFormat, opts.Codebase, opts.Branch, + ); err != nil { + return err + } + + if err := scainternal.ValidateNonEmptyFlag( + "source", cmd.Flags().Changed("source"), opts.Source, + ); err != nil { + return err + } + + if _, err := scainternal.ValidateSeverityCSV(opts.Severity); err != nil { + return err + } + + if runF != nil { + return runF(opts) + } + + return findingsRun(cmd.Context(), opts) + }, + } + + cmd.Flags().StringVar(&opts.Branch, "branch", "", scainternal.BranchFlagUsage) + cmd.Flags().StringSliceVar(&opts.Severity, "severity", nil, scainternal.SeverityFlagUsage) + cmd.Flags().BoolVar(&opts.IncludeSuppressed, "include-suppressed", false, + "Include findings whose analysis is marked suppressed") + cmd.Flags().StringVar(&opts.Source, "source", "", + "Filter by vulnerability data source (e.g. NVD, GITHUB, OSV)") + cmd.Flags().StringVarP(&opts.OutputFormat, "output", "o", "", + "Output format: table, json (default: table)") + + return cmd +} + +func findingsRun(ctx context.Context, opts *FindingsOptions) error { + client, err := opts.RestClient() + if err != nil { + return scainternal.HandleError(opts.IO, opts.OutputFormat, err) + } + + result, err := portal.NewSCAService(client).Findings(ctx, portal.SCAFindingsParams{ + Codebase: opts.Codebase, + Branch: opts.Branch, + IncludeSuppressed: opts.IncludeSuppressed, + Source: opts.Source, + }) + if err != nil { + return scainternal.HandleError(opts.IO, opts.OutputFormat, err) + } + + // Client-side inclusive severity filter (server does not narrow by severity). + if inclusive := scainternal.ExpandSeverityFlag(opts.Severity); len(inclusive) > 0 { + filtered := make([]portal.SCAFinding, 0, len(result.Items)) + for _, f := range result.Items { + if scainternal.SeverityMatches(f.Vulnerability.Severity, inclusive) { + filtered = append(filtered, f) + } + } + result.Items = filtered + // Once the user has narrowed by --severity, the upstream "1000-row cap" + // hint is misleading: they already supplied the only follow-up flag we + // would have suggested. Clear the flag so neither the table footer nor + // the JSON envelope keeps advertising it. + result.Truncated = false + } + + return scainternal.Render(opts.IO, opts.OutputFormat, result, func(w io.Writer, isTTY bool) error { + return renderTable(w, isTTY, opts.Codebase, result) + }) +} + +func renderTable(w io.Writer, isTTY bool, codebase string, result *portal.SCAFindingList) error { + if result.Status == portal.SCAStatusNone { + _, err := fmt.Fprintf(w, "status: NONE — no SCA scanner bound for %s\n", codebase) + return err + } + + headers := []string{"COMPONENT", "VERSION", "VULN_ID", "SRC", "SEVERITY", "CVSS", "ANALYSIS", "SUPPRESSED"} + rows := make([][]string, 0, len(result.Items)) + + for _, f := range result.Items { + compName := f.Component.Name + vulnID := f.Vulnerability.VulnID + if isTTY { + compName = output.Truncate(compName, maxCompColumnWidth) + vulnID = output.Truncate(vulnID, maxIDColumnWidth) + } + + severity := f.Vulnerability.Severity + if isTTY { + severity = output.SCASeverityColor(f.Vulnerability.Severity) + } + + rows = append(rows, []string{ + compName, + f.Component.Version, + vulnID, + f.Vulnerability.Source, + severity, + formatCVSS(f.Vulnerability.CvssV3BaseScore, f.Vulnerability.CvssV2BaseScore), + f.Analysis.State, + scainternal.BoolYesNo(f.Analysis.IsSuppressed), + }) + } + + if err := scainternal.PrintTable(w, isTTY, headers, rows); err != nil { + return err + } + + if result.Truncated { + if _, err := fmt.Fprintln(w); err != nil { + return err + } + if _, err := fmt.Fprintln(w, + "(findings truncated to 1000 rows — narrow the query via --severity or --source)"); err != nil { + return err + } + } + + return nil +} + +func formatCVSS(v3, v2 float32) string { + if v3 > 0 { + return fmt.Sprintf("%.1f (v3)", v3) + } + if v2 > 0 { + return fmt.Sprintf("%.1f (v2)", v2) + } + return scainternal.EmptyPlaceholder +} diff --git a/pkg/cmd/sca/findings/findings_test.go b/pkg/cmd/sca/findings/findings_test.go new file mode 100644 index 0000000..3bf8ae2 --- /dev/null +++ b/pkg/cmd/sca/findings/findings_test.go @@ -0,0 +1,164 @@ +package findings + +import ( + "bytes" + "strings" + "testing" + + "github.com/KubeRocketCI/cli/internal/portal" + "github.com/KubeRocketCI/cli/pkg/cmd/sca/internal/scatestutil" +) + +func TestFindings_RunFInjection(t *testing.T) { + t.Parallel() + + var captured *FindingsOptions + cmd := NewCmdFindings(scatestutil.NewFactory(), func(o *FindingsOptions) error { + captured = o + return nil + }) + cmd.SetArgs([]string{"svc", "--severity", "critical", "--include-suppressed", "--source", "NVD", "--branch", "main"}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + if captured == nil { + t.Fatal("runF not invoked") + } + if captured.Source != "NVD" || !captured.IncludeSuppressed || captured.Branch != "main" { + t.Errorf("captured = %+v", captured) + } + if len(captured.Severity) != 1 || captured.Severity[0] != "critical" { + t.Errorf("Severity = %v, want [critical]", captured.Severity) + } +} + +func TestFindings_RejectsInvalidSeverity(t *testing.T) { + t.Parallel() + + cmd := NewCmdFindings(scatestutil.NewFactory(), nil) + cmd.SetArgs([]string{"svc", "--severity", "garbage"}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "invalid --severity") { + t.Errorf("expected severity error, got %v", err) + } +} + +func TestFindings_RejectsEmptySource(t *testing.T) { + t.Parallel() + + cmd := NewCmdFindings(scatestutil.NewFactory(), nil) + cmd.SetArgs([]string{"svc", "--source", ""}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "--source requires a non-empty value") { + t.Errorf("expected non-empty-source error, got %v", err) + } +} + +func TestFindings_RejectsPRFlag(t *testing.T) { + t.Parallel() + + cmd := NewCmdFindings(scatestutil.NewFactory(), nil) + cmd.SetArgs([]string{"svc", "--pr", "42"}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "unknown flag") { + t.Errorf("expected unknown flag error, got %v", err) + } +} + +func TestFindings_RequiresCodebase(t *testing.T) { + t.Parallel() + + cmd := NewCmdFindings(scatestutil.NewFactory(), nil) + cmd.SetArgs([]string{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + if err := cmd.Execute(); err == nil { + t.Fatal("expected error") + } +} + +func TestFindings_SeverityFlagUsageVerbatim(t *testing.T) { + t.Parallel() + + cmd := NewCmdFindings(scatestutil.NewFactory(), nil) + flag := cmd.Flag("severity") + if flag == nil { + t.Fatal("--severity missing") + } + if !strings.Contains(flag.Usage, "inclusive") || !strings.Contains(flag.Usage, "UNASSIGNED") { + t.Errorf("--severity usage must document inclusive semantics + UNASSIGNED, got %q", flag.Usage) + } +} + +func TestRenderTable_NONE(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + if err := renderTable(&buf, false, "svc", + &portal.SCAFindingList{Status: portal.SCAStatusNone}); err != nil { + t.Fatalf("renderTable: %v", err) + } + if !strings.Contains(buf.String(), "status: NONE") { + t.Errorf("want NONE banner, got %q", buf.String()) + } +} + +func TestRenderTable_TruncationHint(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + err := renderTable(&buf, false, "svc", &portal.SCAFindingList{ + Status: portal.SCAStatusOK, + Items: []portal.SCAFinding{}, + Truncated: true, + }) + if err != nil { + t.Fatalf("renderTable: %v", err) + } + if !strings.Contains(buf.String(), "truncated to 1000 rows") { + t.Errorf("expected truncation hint, got %q", buf.String()) + } +} + +func TestRenderTable_NoHintWhenNotTruncated(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + err := renderTable(&buf, false, "svc", &portal.SCAFindingList{ + Status: portal.SCAStatusOK, + Items: []portal.SCAFinding{}, + Truncated: false, + }) + if err != nil { + t.Fatalf("renderTable: %v", err) + } + if strings.Contains(buf.String(), "truncated") { + t.Errorf("truncation hint must be absent, got %q", buf.String()) + } +} + +func TestFormatCVSS(t *testing.T) { + t.Parallel() + + if got := formatCVSS(9.8, 0); !strings.Contains(got, "9.8") || !strings.Contains(got, "v3") { + t.Errorf("v3 got %q", got) + } + if got := formatCVSS(0, 7.5); !strings.Contains(got, "7.5") || !strings.Contains(got, "v2") { + t.Errorf("v2 got %q", got) + } + if got := formatCVSS(0, 0); got != "—" { + t.Errorf("both zero got %q", got) + } +} diff --git a/pkg/cmd/sca/get/get.go b/pkg/cmd/sca/get/get.go new file mode 100644 index 0000000..44ef688 --- /dev/null +++ b/pkg/cmd/sca/get/get.go @@ -0,0 +1,258 @@ +// Package get implements the "krci sca get" command. +package get + +import ( + "cmp" + "context" + "fmt" + "io" + "time" + + "github.com/spf13/cobra" + + "github.com/KubeRocketCI/cli/internal/cmdutil" + "github.com/KubeRocketCI/cli/internal/iostreams" + "github.com/KubeRocketCI/cli/internal/output" + "github.com/KubeRocketCI/cli/internal/portal" + "github.com/KubeRocketCI/cli/internal/portal/restapi" + scainternal "github.com/KubeRocketCI/cli/pkg/cmd/sca/internal" +) + +// GetOptions holds all inputs for `krci sca get `. +type GetOptions struct { + IO *iostreams.IOStreams + RestClient func() (*restapi.ClientWithResponses, error) + Codebase string + Branch string + OutputFormat string +} + +// NewCmdGet returns the "sca get" cobra.Command. +func NewCmdGet(f *cmdutil.Factory, runF func(*GetOptions) error) *cobra.Command { + opts := &GetOptions{ + IO: f.IOStreams, + RestClient: f.RestClient, + } + + cmd := &cobra.Command{ + Use: "get ", + Short: "Show a Dep-Track project's overview for a codebase", + Args: cmdutil.ExactArgs(1, "a KubeRocketCI codebase name", + "to see available projects: krci sca list"), + Example: ` # Uses Codebase.spec.defaultBranch when --branch is omitted + krci sca get payments-api + + # Explicit branch / release tag + krci sca get payments-api --branch=release/1.0 + + # Scripting — risk score for the default branch + krci sca get payments-api -o json | jq -r '.data.project.riskScore'`, + RunE: func(cmd *cobra.Command, args []string) error { + opts.Codebase = args[0] + + if err := scainternal.ValidateCodebaseCommand( + cmd, opts.OutputFormat, opts.Codebase, opts.Branch, + ); err != nil { + return err + } + + if runF != nil { + return runF(opts) + } + + return getRun(cmd.Context(), opts) + }, + } + + cmd.Flags().StringVar(&opts.Branch, "branch", "", scainternal.BranchFlagUsage) + cmd.Flags().StringVarP(&opts.OutputFormat, "output", "o", "", + "Output format: table, json (default: table)") + + return cmd +} + +func getRun(ctx context.Context, opts *GetOptions) error { + client, err := opts.RestClient() + if err != nil { + return scainternal.HandleError(opts.IO, opts.OutputFormat, err) + } + + detail, err := portal.NewSCAService(client).Get(ctx, opts.Codebase, opts.Branch) + if err != nil { + return scainternal.HandleError(opts.IO, opts.OutputFormat, err) + } + + return scainternal.Render(opts.IO, opts.OutputFormat, detail, func(w io.Writer, isTTY bool) error { + return printDetail(w, opts.Codebase, opts.Branch, detail, isTTY) + }) +} + +// kvPair is one aligned "label: value" row in the `get` output. display holds +// the optional styled rendering of the value (used for coloured severity +// badges); when display is empty, value is rendered verbatim. +type kvPair struct { + label string + value string + display string +} + +func printDetail(w io.Writer, codebase, requestedBranch string, d *portal.SCAProjectDetail, styled bool) error { + if d.Status == portal.SCAStatusNone { + branchLabel := cmp.Or(requestedBranch, "default branch") + if _, err := fmt.Fprintf(w, "status: NONE — no SCA scanner bound for %s @ %s\n", codebase, branchLabel); err != nil { + return err + } + return nil + } + + if d.Project == nil { + return fmt.Errorf("portal returned status=OK without project") + } + + project := d.Project + + // Header line shows which branch was queried; when --branch was omitted, + // the Portal fills in the resolved default and returns it verbatim. + if _, err := fmt.Fprintf(w, "%s @ %s\n\n", project.Name, project.Version); err != nil { + return err + } + + pairs := []kvPair{ + {label: "Codebase", value: codebase}, + {label: "Branch", value: project.Version}, + {label: "Classifier", value: scainternal.OrDash(project.Classifier)}, + {label: "Active", value: formatActive(project.Active, styled)}, + {label: "Is latest", value: scainternal.BoolYesNo(project.IsLatest)}, + {label: "Risk score", value: scainternal.FormatRisk(project.RiskScore)}, + {label: "Last BOM", value: formatTimestamp(project.LastBomImport, project.LastBomImportFormat)}, + {label: "Last vuln scan", value: formatRelativeTimestamp(project.LastVulnerabilityAnalysis)}, + } + if err := printAligned(w, pairs, "", styled); err != nil { + return err + } + + if d.Metrics == nil { + return nil + } + + if _, err := fmt.Fprintln(w); err != nil { + return err + } + + if err := printHeading(w, "Vulnerabilities", styled); err != nil { + return err + } + + vulnPairs := []kvPair{ + severityRow("Critical", "CRITICAL", d.Metrics.Critical, styled), + severityRow("High", "HIGH", d.Metrics.High, styled), + severityRow("Medium", "MEDIUM", d.Metrics.Medium, styled), + severityRow("Low", "LOW", d.Metrics.Low, styled), + severityRow("Info/Unassigned", "INFO", d.Metrics.Unassigned, styled), + {label: "Total", value: fmt.Sprintf("%d", d.Metrics.Vulnerabilities)}, + } + if err := printAligned(w, vulnPairs, " ", styled); err != nil { + return err + } + + if _, err := fmt.Fprintln(w); err != nil { + return err + } + + if err := printHeading(w, "Components", styled); err != nil { + return err + } + + compPairs := []kvPair{ + {label: "Total", value: fmt.Sprintf("%d", d.Metrics.Components)}, + {label: "Vulnerable", value: fmt.Sprintf("%d", d.Metrics.VulnerableComponents)}, + } + return printAligned(w, compPairs, " ", styled) +} + +// severityRow builds a "label: count" kvPair. When styled && count>0, the +// value is augmented with the coloured canonical severity badge so the user +// sees e.g. "3 CRITICAL" in red. +func severityRow(label, canonical string, count int, styled bool) kvPair { + p := kvPair{label: label, value: fmt.Sprintf("%d", count)} + if styled && count > 0 && canonical != "" { + p.display = fmt.Sprintf("%s %s", p.value, output.SCASeverityColor(canonical)) + } + return p +} + +func formatActive(active, styled bool) string { + if active { + if styled { + return output.GreenText("yes") + } + return "yes" + } + return "no" +} + +func formatTimestamp(ms int64, format string) string { + if ms <= 0 { + return scainternal.EmptyPlaceholder + } + t := time.UnixMilli(ms).UTC() + rel := output.RelativeTime(t) + base := t.Format("2006-01-02 15:04 UTC") + if format != "" { + base = fmt.Sprintf("%s (%s)", base, format) + } + if rel == "" { + return base + } + return fmt.Sprintf("%s, %s", base, rel) +} + +func formatRelativeTimestamp(ms int64) string { + if ms <= 0 { + return scainternal.EmptyPlaceholder + } + return output.RelativeTime(time.UnixMilli(ms)) +} + +// printAligned / printHeading mirror the sonar helpers verbatim so the two +// groups render with the same visual rhythm. +var scaLabelStyle = output.LabelStyle.UnsetWidth() + +func printAligned(w io.Writer, pairs []kvPair, indent string, styled bool) error { + maxLabel := 0 + for _, p := range pairs { + if len(p.label) > maxLabel { + maxLabel = len(p.label) + } + } + + for _, p := range pairs { + pad := maxLabel - len(p.label) + value := p.display + if value == "" { + value = p.value + } + + var err error + if styled { + _, err = fmt.Fprintf(w, "%s%s%*s %s\n", indent, scaLabelStyle.Render(p.label), pad, "", value) + } else { + _, err = fmt.Fprintf(w, "%s%s%*s %s\n", indent, p.label, pad, "", value) + } + if err != nil { + return err + } + } + + return nil +} + +func printHeading(w io.Writer, title string, styled bool) error { + if styled { + _, err := fmt.Fprintln(w, output.HeaderStyle.Render(title)) + return err + } + + _, err := fmt.Fprintln(w, title+":") + return err +} diff --git a/pkg/cmd/sca/get/get_test.go b/pkg/cmd/sca/get/get_test.go new file mode 100644 index 0000000..09f1bd5 --- /dev/null +++ b/pkg/cmd/sca/get/get_test.go @@ -0,0 +1,195 @@ +package get + +import ( + "bytes" + "strings" + "testing" + + "github.com/KubeRocketCI/cli/internal/portal" + "github.com/KubeRocketCI/cli/pkg/cmd/sca/internal/scatestutil" +) + +const ( + yesLabel = "yes" + noLabel = "no" + dashPad = "—" +) + +func TestGet_RequiresExactlyOneArg(t *testing.T) { + t.Parallel() + + cmd := NewCmdGet(scatestutil.NewFactory(), nil) + cmd.SetArgs([]string{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + if err := cmd.Execute(); err == nil { + t.Fatal("expected error for missing codebase") + } +} + +func TestGet_RejectsInvalidCodebase(t *testing.T) { + t.Parallel() + + cmd := NewCmdGet(scatestutil.NewFactory(), nil) + cmd.SetArgs([]string{"Not_A_DNS_Label"}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "DNS-1123") { + t.Errorf("expected DNS-1123 error, got %v", err) + } +} + +func TestGet_RejectsPRFlag(t *testing.T) { + t.Parallel() + // --pr is intentionally not registered on sca verbs. cobra must reject it. + cmd := NewCmdGet(scatestutil.NewFactory(), nil) + cmd.SetArgs([]string{"svc", "--pr", "42"}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "unknown flag") { + t.Errorf("expected unknown flag error, got %v", err) + } +} + +func TestGet_RunFInjectionWithBranch(t *testing.T) { + t.Parallel() + + var captured *GetOptions + cmd := NewCmdGet(scatestutil.NewFactory(), func(o *GetOptions) error { + captured = o + return nil + }) + cmd.SetArgs([]string{"svc", "--branch", "release/1.0"}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + if captured == nil || captured.Codebase != "svc" || captured.Branch != "release/1.0" { + t.Errorf("captured = %+v", captured) + } +} + +func TestGet_BranchFlagUsageStringIsVerbatim(t *testing.T) { + t.Parallel() + + cmd := NewCmdGet(scatestutil.NewFactory(), nil) + flag := cmd.Flag("branch") + if flag == nil { + t.Fatal("--branch flag missing") + } + if !strings.Contains(flag.Usage, "Dep-Track project 'version'") { + t.Errorf("--branch usage must reference Dep-Track version field: %q", flag.Usage) + } + if !strings.Contains(flag.Usage, "krci sca list --search=") { + t.Errorf("--branch usage must hint discovery path: %q", flag.Usage) + } +} + +func TestPrintDetail_NONE(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + err := printDetail(&buf, "svc", "main", &portal.SCAProjectDetail{Status: portal.SCAStatusNone}, false) + if err != nil { + t.Fatalf("printDetail: %v", err) + } + out := buf.String() + if !strings.Contains(out, "status: NONE") { + t.Errorf("expected NONE banner, got %q", out) + } + if !strings.Contains(out, "svc") || !strings.Contains(out, "main") { + t.Errorf("output must include codebase and branch: %q", out) + } +} + +func TestPrintDetail_HappyPath(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + detail := &portal.SCAProjectDetail{ + Status: portal.SCAStatusOK, + Project: &portal.SCAProject{ + UUID: "u1", + Name: "svc", + Version: "main", + Classifier: "APPLICATION", + Active: true, + IsLatest: true, + RiskScore: 12.5, + }, + Metrics: &portal.SCAMetrics{ + Critical: 3, + High: 4, + Medium: 5, + Low: 6, + Unassigned: 2, + Vulnerabilities: 20, + Components: 100, + VulnerableComponents: 10, + }, + } + + if err := printDetail(&buf, "svc", "", detail, false); err != nil { + t.Fatalf("printDetail: %v", err) + } + out := buf.String() + wants := []string{"svc @ main", "Codebase", "Branch", "Classifier", "Risk score", + "Vulnerabilities", "Critical", "Components", "Total"} + for _, w := range wants { + if !strings.Contains(out, w) { + t.Errorf("output must contain %q, got %q", w, out) + } + } +} + +func TestFormatters(t *testing.T) { + t.Parallel() + if formatActive(true, false) != yesLabel { + t.Error("formatActive true") + } + if formatActive(false, true) != noLabel { + t.Error("formatActive false") + } + if formatTimestamp(0, "") != dashPad { + t.Error("formatTimestamp zero") + } + if formatRelativeTimestamp(0) != dashPad { + t.Error("formatRelativeTimestamp zero") + } +} + +func TestSeverityRow(t *testing.T) { + t.Parallel() + + // Plain (non-styled) renders just label + numeric value. + p := severityRow("Critical", "CRITICAL", 3, false) + if p.label != "Critical" || p.value != "3" || p.display != "" { + t.Errorf("plain severityRow: %+v", p) + } + + // Styled + count>0 attaches a coloured badge in display; the numeric value + // must stay as the leading token so the JSON layer keeps using it. + p = severityRow("Critical", "CRITICAL", 3, true) + if !strings.HasPrefix(p.display, "3 ") { + t.Errorf("styled display must start with count, got %q", p.display) + } + + // Styled but count==0 must not attach a badge (0 counts stay neutral). + p = severityRow("Critical", "CRITICAL", 0, true) + if p.display != "" { + t.Errorf("zero count must not render a badge, got %q", p.display) + } + + // Empty canonical severity is a no-op on display. + p = severityRow("Other", "", 5, true) + if p.display != "" { + t.Errorf("empty canonical must not render a badge, got %q", p.display) + } +} diff --git a/pkg/cmd/sca/internal/render.go b/pkg/cmd/sca/internal/render.go new file mode 100644 index 0000000..d17f246 --- /dev/null +++ b/pkg/cmd/sca/internal/render.go @@ -0,0 +1,159 @@ +package scainternal + +import ( + "errors" + "fmt" + "io" + + "github.com/KubeRocketCI/cli/internal/cmdutil" + "github.com/KubeRocketCI/cli/internal/iostreams" + "github.com/KubeRocketCI/cli/internal/output" + "github.com/KubeRocketCI/cli/internal/portal" +) + +// SchemaVersion is the shared JSON envelope version for every `krci sca *` verb. +const SchemaVersion = "1" + +// TableRenderer emits the table/text view of an sca verb's payload. The +// callback receives the output writer and the TTY state so it can apply +// styling or truncation conditionally. +type TableRenderer func(w io.Writer, isTTY bool) error + +// Render dispatches on the `-o` flag: emit `{schemaVersion, data}` for +// `-o json`, invoke the TableRenderer for `-o table` (the default), or return +// a validation error for anything else. +func Render[T any](ios *iostreams.IOStreams, outputFormat string, data T, renderTable TableRenderer) error { + format := output.ResolveFormat(outputFormat) + + switch format { + case output.FormatJSON: + return output.PrintJSONEnvelope(ios.Out, SchemaVersion, data) + case output.FormatTable: + return renderTable(ios.Out, ios.IsStdoutTTY()) + default: + return fmt.Errorf("unknown output format: %s (use 'json' or 'table')", format) + } +} + +// HandleError promotes portal.ErrUnauthorized to the "run krci auth login" +// message; translates portal.ErrUpstreamUnavailable into a user-facing +// "upstream unavailable (dependency-track)" hint; and — when `-o json` is +// selected — also writes a structured error envelope to stdout so scripting +// consumers can read it alongside the exit-1 signal. +func HandleError(ios *iostreams.IOStreams, outputFormat string, err error) error { + if errors.Is(err, portal.ErrUnauthorized) { + err = cmdutil.ErrAuthRequired(err) + } else if errors.Is(err, portal.ErrUpstreamUnavailable) { + err = fmt.Errorf("upstream unavailable (dependency-track): %w", err) + } + + if output.ResolveFormat(outputFormat) == output.FormatJSON { + _ = output.PrintJSONErrorEnvelope(ios.Out, SchemaVersion, err) + } + + return err +} + +// PrintTable is the shared TTY/non-TTY dispatcher for sca verbs that show a +// plain 2D table. +func PrintTable(w io.Writer, isTTY bool, headers []string, rows [][]string) error { + if isTTY { + return output.PrintStyledTable(w, headers, rows) + } + + return output.PrintTable(w, headers, rows) +} + +// PageCount returns the number of pages of pageSize required to hold total +// items. Always returns at least 1 so footers never read "page 1 of 0". +func PageCount(total, pageSize int) int { + if pageSize <= 0 { + return 1 + } + + n := total / pageSize + if total%pageSize != 0 { + n++ + } + + if n < 1 { + return 1 + } + + return n +} + +// PageFooter formats the paging summary printed below a paginated sca table. +func PageFooter(noun string, total, pageIndex, pageSize int) string { + totalPages := PageCount(total, pageSize) + + if total == 0 { + return fmt.Sprintf("no %s (page-size %d)", pluralise(noun, 0), pageSize) + } + + word := pluralise(noun, total) + + if pageIndex > totalPages { + return fmt.Sprintf( + "%d %s — page %d is beyond last page %d (page-size %d)", + total, word, pageIndex, totalPages, pageSize, + ) + } + + return fmt.Sprintf( + "%d %s, page %d of %d (page-size %d)", + total, word, pageIndex, totalPages, pageSize, + ) +} + +// pluralise does the "1 project" / "N projects" / "0 projects" English plural +// for footer output. No i18n here — identical to sonar's inline helper. +func pluralise(noun string, n int) string { + if n == 1 { + return noun + } + return noun + "s" +} + +// EmptyPlaceholder is the unicode em-dash used in TTY output for missing values. +const EmptyPlaceholder = "—" + +// OrDash returns s when non-empty, otherwise EmptyPlaceholder. Shared by every +// sca verb that renders a potentially-empty column value. +func OrDash(s string) string { + if s == "" { + return EmptyPlaceholder + } + return s +} + +// BoolYesNo renders a bool as the lowercase "yes" / "no" used by every sca +// table column. +func BoolYesNo(b bool) string { + if b { + return "yes" + } + return "no" +} + +// FormatRisk renders a Dep-Track risk score, using EmptyPlaceholder for zero. +func FormatRisk(r float32) string { + if r == 0 { + return EmptyPlaceholder + } + return fmt.Sprintf("%.1f", r) +} + +// FormatVulnCounts renders the C/H/M/L vulnerability counts column. When isTTY +// is true and there is at least one critical or high finding, the string is +// highlighted yellow so it visually pops in interactive listings. +func FormatVulnCounts(m *portal.SCAMetrics, isTTY bool) string { + if m == nil { + return "0/0/0/0" + } + s := fmt.Sprintf("%d/%d/%d/%d", m.Critical, m.High, m.Medium, m.Low) + if isTTY && m.Critical+m.High > 0 { + return output.YellowText(s) + } + return s +} diff --git a/pkg/cmd/sca/internal/render_test.go b/pkg/cmd/sca/internal/render_test.go new file mode 100644 index 0000000..ddb8a85 --- /dev/null +++ b/pkg/cmd/sca/internal/render_test.go @@ -0,0 +1,208 @@ +package scainternal + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "strings" + "testing" + + "github.com/KubeRocketCI/cli/internal/iostreams" + "github.com/KubeRocketCI/cli/internal/portal" +) + +type testPayload struct { + Message string `json:"message"` +} + +func newStreams() (*iostreams.IOStreams, *bytes.Buffer) { + out := &bytes.Buffer{} + errOut := &bytes.Buffer{} + return &iostreams.IOStreams{Out: out, ErrOut: errOut}, out +} + +func TestRender_JSONEnvelope(t *testing.T) { + t.Parallel() + + ios, out := newStreams() + payload := testPayload{Message: "hello"} + if err := Render(ios, "json", payload, func(io.Writer, bool) error { + t.Fatal("table renderer must not run for -o json") + return nil + }); err != nil { + t.Fatalf("Render error: %v", err) + } + + var env struct { + SchemaVersion string `json:"schemaVersion"` + Data testPayload `json:"data"` + } + if err := json.Unmarshal(out.Bytes(), &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.SchemaVersion != SchemaVersion { + t.Errorf("schemaVersion = %q; want %q", env.SchemaVersion, SchemaVersion) + } + if env.Data.Message != "hello" { + t.Errorf("data.message = %q; want hello", env.Data.Message) + } +} + +func TestRender_TableInvokesCallback(t *testing.T) { + t.Parallel() + + ios, out := newStreams() + called := false + err := Render(ios, "", testPayload{Message: "x"}, func(w io.Writer, isTTY bool) error { + called = true + _, _ = w.Write([]byte("ok")) + return nil + }) + if err != nil { + t.Fatalf("err: %v", err) + } + if !called { + t.Fatal("table callback must run for default format") + } + if out.String() != "ok" { + t.Errorf("out = %q", out.String()) + } +} + +func TestRender_UnknownFormatErrors(t *testing.T) { + t.Parallel() + ios, _ := newStreams() + err := Render(ios, "yaml", testPayload{}, func(io.Writer, bool) error { return nil }) + if err == nil || !strings.Contains(err.Error(), "unknown output format") { + t.Errorf("expected unknown format error, got %v", err) + } +} + +func TestHandleError_UnauthorizedPromotes(t *testing.T) { + t.Parallel() + ios, _ := newStreams() + got := HandleError(ios, "", portal.ErrUnauthorized) + if got == nil || !strings.Contains(got.Error(), "krci auth login") { + t.Errorf("expected auth-login message, got %v", got) + } +} + +func TestHandleError_UpstreamUnavailableAnnotates(t *testing.T) { + t.Parallel() + ios, _ := newStreams() + got := HandleError(ios, "", portal.ErrUpstreamUnavailable) + if got == nil || !strings.Contains(got.Error(), "dependency-track") { + t.Errorf("expected dep-track hint, got %v", got) + } + if !errors.Is(got, portal.ErrUpstreamUnavailable) { + t.Error("wrap must preserve ErrUpstreamUnavailable") + } +} + +func TestHandleError_JSONEnvelopePrinted(t *testing.T) { + t.Parallel() + ios, out := newStreams() + _ = HandleError(ios, "json", errors.New("kaboom")) + + var env struct { + SchemaVersion string `json:"schemaVersion"` + Error struct { + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(out.Bytes(), &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.SchemaVersion != SchemaVersion { + t.Errorf("schemaVersion = %q", env.SchemaVersion) + } + if env.Error.Message != "kaboom" { + t.Errorf("error.message = %q", env.Error.Message) + } +} + +func TestHandleError_PlainTableFormatDoesNotEmitJSONEnvelope(t *testing.T) { + t.Parallel() + ios, out := newStreams() + _ = HandleError(ios, "table", errors.New("kaboom")) + if out.Len() != 0 { + t.Errorf("table format must not write JSON envelope to stdout, got %q", out.String()) + } +} + +func TestPageCount(t *testing.T) { + t.Parallel() + cases := []struct { + total, pageSize, want int + }{ + {0, 25, 1}, + {1, 25, 1}, + {25, 25, 1}, + {26, 25, 2}, + {50, 25, 2}, + {51, 25, 3}, + {100, 0, 1}, // defensive: zero page size → 1 + } + for _, c := range cases { + if got := PageCount(c.total, c.pageSize); got != c.want { + t.Errorf("PageCount(%d,%d) = %d want %d", c.total, c.pageSize, got, c.want) + } + } +} + +func TestBoolYesNo(t *testing.T) { + t.Parallel() + if BoolYesNo(true) != "yes" || BoolYesNo(false) != "no" { + t.Errorf("BoolYesNo: true=%q false=%q", BoolYesNo(true), BoolYesNo(false)) + } +} + +func TestFormatRisk(t *testing.T) { + t.Parallel() + if FormatRisk(0) != EmptyPlaceholder { + t.Errorf("zero must render as %q, got %q", EmptyPlaceholder, FormatRisk(0)) + } + if got := FormatRisk(12.345); !strings.Contains(got, "12.3") { + t.Errorf("FormatRisk(12.345) = %q; want substring 12.3", got) + } +} + +func TestFormatVulnCounts(t *testing.T) { + t.Parallel() + if got := FormatVulnCounts(nil, false); got != "0/0/0/0" { + t.Errorf("nil metrics = %q; want 0/0/0/0", got) + } + m := &portal.SCAMetrics{Critical: 1, High: 2, Medium: 3, Low: 4} + if got := FormatVulnCounts(m, false); got != "1/2/3/4" { + t.Errorf("non-TTY = %q; want 1/2/3/4", got) + } + // TTY mode wraps in YellowText when critical+high > 0; just assert the + // digits remain present (we don't depend on the exact ANSI sequence). + if got := FormatVulnCounts(m, true); !strings.Contains(got, "1/2/3/4") { + t.Errorf("TTY render must still contain digits, got %q", got) + } +} + +func TestPageFooter(t *testing.T) { + t.Parallel() + cases := []struct { + noun string + total, pageIndex, pageSize int + wantSubstr string + wantSingular bool + wantBeyondLastPage, wantNone bool + }{ + {"project", 0, 1, 25, "no projects", false, false, true}, + {"project", 1, 1, 25, "1 project,", true, false, false}, + {"project", 42, 2, 25, "42 projects, page 2 of 2", false, false, false}, + {"project", 42, 99, 25, "beyond last page", false, true, false}, + } + for _, c := range cases { + got := PageFooter(c.noun, c.total, c.pageIndex, c.pageSize) + if !strings.Contains(got, c.wantSubstr) { + t.Errorf("PageFooter(%s,%d,%d,%d) = %q; want substr %q", + c.noun, c.total, c.pageIndex, c.pageSize, got, c.wantSubstr) + } + } +} diff --git a/pkg/cmd/sca/internal/scatestutil/scatestutil.go b/pkg/cmd/sca/internal/scatestutil/scatestutil.go new file mode 100644 index 0000000..952d376 --- /dev/null +++ b/pkg/cmd/sca/internal/scatestutil/scatestutil.go @@ -0,0 +1,25 @@ +// Package scatestutil exposes helpers shared by every `krci sca ` test +// package — primarily a Factory stub wired to in-memory streams and a nil +// portal client suitable for RunE plumbing tests. +package scatestutil + +import ( + "bytes" + + "github.com/KubeRocketCI/cli/internal/cmdutil" + "github.com/KubeRocketCI/cli/internal/iostreams" + "github.com/KubeRocketCI/cli/internal/portal/restapi" +) + +// NewFactory returns a Factory with buffered stdout/stderr and a RestClient +// closure that yields (nil, nil). Tests that exercise the pre-network +// validation path — flag parsing, arg checks, bounds, severity canonicalisation +// — can drive a cobra.Command through Execute without touching the network. +func NewFactory() *cmdutil.Factory { + return &cmdutil.Factory{ + IOStreams: &iostreams.IOStreams{Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{}}, + RestClient: func() (*restapi.ClientWithResponses, error) { + return nil, nil + }, + } +} diff --git a/pkg/cmd/sca/internal/validate.go b/pkg/cmd/sca/internal/validate.go new file mode 100644 index 0000000..10d3c8b --- /dev/null +++ b/pkg/cmd/sca/internal/validate.go @@ -0,0 +1,258 @@ +// Package scainternal holds helpers shared by `krci sca` verbs +// (validation, enum sets, render/format dispatch, error-envelope plumbing). +// Structure mirrors `pkg/cmd/sonar/internal/`. +package scainternal + +import ( + "fmt" + "slices" + "strings" + + "github.com/spf13/cobra" + + "github.com/KubeRocketCI/cli/internal/cmdutil" + "github.com/KubeRocketCI/cli/internal/output" +) + +// MaxPageSize is the upper bound on `--page-size` for every paginated sca +// verb. Matches the OpenAPI ceiling. +const MaxPageSize = 500 + +// BranchFlagUsage is the verbatim help text for `--branch` across every +// per-codebase sca verb. Spec Requirement "Codebase + Branch Addressing" +// mandates this exact string so users always see the same Dep-Track `version` +// field explanation. +const BranchFlagUsage = "branch name (maps to the Dep-Track project 'version' field). " + + "Defaults to the codebase's spec.defaultBranch. " + + "Run 'krci sca list --search=' to discover all recorded versions." + +// SeverityFlagUsage is the verbatim help text for `--severity` across the +// verbs that expose it. Spec design §D4 mandates this exact string: it +// enumerates the allowed values and explains the inclusive semantics. +const SeverityFlagUsage = "minimum severity to include (inclusive). " + + "One of: critical, high, medium, low, info. Case-insensitive. " + + "'high' returns high+critical; 'medium' returns medium+high+critical; etc. " + + "INFO includes UNASSIGNED." + +// ValidateCodebaseKey mirrors sonarinternal.ValidateProjectKey — codebase +// names by platform convention follow DNS-1123. +func ValidateCodebaseKey(codebase string) error { + if codebase == "" { + return fmt.Errorf(" must not be empty") + } + + if len(codebase) > cmdutil.DNS1123SubdomainMaxLength { + return fmt.Errorf(" must be at most %d characters", cmdutil.DNS1123SubdomainMaxLength) + } + + if !cmdutil.IsValidDNS1123Label(codebase) { + return fmt.Errorf(" must be a valid DNS-1123 name") + } + + return nil +} + +// ValidateNonEmptyFlag rejects a flag that was explicitly supplied with an +// empty value. changed is true when the flag was set on the command line. +func ValidateNonEmptyFlag(name string, changed bool, value string) error { + if changed && value == "" { + return fmt.Errorf("--%s requires a non-empty value", name) + } + + return nil +} + +// ValidatePageBounds rejects out-of-range --page / --page-size values. Shared +// between every paginated sca verb so the ceiling is enforced uniformly. +func ValidatePageBounds(page, pageSize int) error { + if pageSize < 1 || pageSize > MaxPageSize { + return fmt.Errorf("--page-size must be between 1 and %d", MaxPageSize) + } + if page < 1 { + return fmt.Errorf("--page must be >= 1") + } + return nil +} + +// ValidateOutputFormat rejects `-o` values other than "", "table", or "json". +// An empty string means "use default" (table) and is always valid. +func ValidateOutputFormat(format string) error { + switch format { + case "", output.FormatTable, output.FormatJSON: + return nil + default: + return fmt.Errorf("unknown output format: %s (use 'json' or 'table')", format) + } +} + +// ValidateCodebaseCommand runs the validator chain used by every per-codebase +// sca verb (get, components, findings): output format → DNS-1123 codebase +// → non-empty --branch (when explicitly supplied). Unlike sonar, sca never +// takes --pr, so there is no scope-mutex step. +func ValidateCodebaseCommand(cmd *cobra.Command, outputFormat, codebase, branch string) error { + if err := ValidateOutputFormat(outputFormat); err != nil { + return err + } + + if err := ValidateCodebaseKey(codebase); err != nil { + return err + } + + return ValidateNonEmptyFlag("branch", cmd.Flags().Changed("branch"), branch) +} + +// Canonical Dep-Track severity values in descending severity order. +// UNASSIGNED is filtered together with INFO per design §D4 — see +// InclusiveSeverities. +var severityOrder = []string{"CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO", "UNASSIGNED"} + +// SeverityEnum is the set of values accepted by the `--severity` flag. +// Lowercase "unassigned" is intentionally omitted from the user-facing list +// (it is reachable via `--severity=info`) but is still accepted if typed +// explicitly, for scripting symmetry with the upstream payload values. +var allowedSeverityInputs = map[string]string{ + "critical": "CRITICAL", + "high": "HIGH", + "medium": "MEDIUM", + "low": "LOW", + "info": "INFO", + "unassigned": "UNASSIGNED", +} + +// ValidateSeverityCSV canonicalises the comma-separated / repeatable values +// in values to upper-case Dep-Track severities. Empty input returns nil. An +// unknown value returns an error listing the accepted values. +// +// The function returns validated values in input order with duplicates +// collapsed — this makes the test expectations stable. +func ValidateSeverityCSV(values []string) ([]string, error) { + if len(values) == 0 { + return nil, nil + } + + expanded := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + + for _, v := range values { + for part := range strings.SplitSeq(v, ",") { + p := strings.TrimSpace(part) + if p == "" { + continue + } + + upper, ok := allowedSeverityInputs[strings.ToLower(p)] + if !ok { + return nil, fmt.Errorf( + "invalid --severity=%s; must be one of %s", + p, strings.Join(severityOrder, ", "), + ) + } + + if _, dup := seen[upper]; dup { + continue + } + seen[upper] = struct{}{} + + expanded = append(expanded, upper) + } + } + + return expanded, nil +} + +// InclusiveSeverities returns the severities that should be included when the +// user supplies `--severity=`. The return set is "min and above": e.g. +// `HIGH` → `{CRITICAL, HIGH}`; `INFO` → `{CRITICAL, HIGH, MEDIUM, LOW, INFO, +// UNASSIGNED}`. An unknown min returns nil so callers can treat that as +// "no filter" (already validated upstream). +// +// severityOrder is in descending severity (CRITICAL first); we walk from the +// start through `min` inclusive. +func InclusiveSeverities(min string) []string { + if min == "" { + return nil + } + upper := strings.ToUpper(min) + out := make([]string, 0, len(severityOrder)) + found := false + for _, s := range severityOrder { + out = append(out, s) + if s == upper { + found = true + break + } + } + if !found { + return nil + } + // "INFO" implicitly includes UNASSIGNED — design §D4. UNASSIGNED already + // sits beyond INFO in severityOrder; append it when the selected min is + // INFO so the filter set matches the design intent. + if upper == "INFO" && !slices.Contains(out, "UNASSIGNED") { + out = append(out, "UNASSIGNED") + } + return out +} + +// SeverityMatches returns true if severity is in the allowed set. Empty allowed +// means "no filter" — callers expected to short-circuit before calling. +// Severity is normalised to upper-case so upstream casing variations don't +// silently drop rows (allowed is always canonical upper-case). +func SeverityMatches(severity string, allowed []string) bool { + return slices.Contains(allowed, strings.ToUpper(severity)) +} + +// severityRank ranks Dep-Track severities ascending (UNASSIGNED=0 is least +// severe). Used by InclusiveFromSet to pick the lowest-rank threshold. +var severityRank = map[string]int{ + "CRITICAL": 5, + "HIGH": 4, + "MEDIUM": 3, + "LOW": 2, + "INFO": 1, + "UNASSIGNED": 0, +} + +// ExpandSeverityFlag is the composite helper for run functions: it validates +// the raw `--severity` flag values (silently ignoring the error — callers are +// expected to run ValidateSeverityCSV in RunE first) and returns the inclusive +// "min and above" expansion. nil input returns nil so the caller can skip +// filtering without a branch on slice length. +func ExpandSeverityFlag(raw []string) []string { + validated, _ := ValidateSeverityCSV(raw) + return InclusiveFromSet(validated) +} + +// InclusiveFromSet returns the inclusive severity set derived from an +// already-validated `--severity` value list. When several thresholds are +// supplied, it takes the least-severe one ("medium" when "critical,medium" is +// supplied) so the expansion pulls everything at or above medium — matching +// the intent of "include all of these severities plus higher ones". +func InclusiveFromSet(set []string) []string { + if len(set) == 0 { + return nil + } + min := set[0] + for _, s := range set[1:] { + if severityRank[s] < severityRank[min] { + min = s + } + } + return InclusiveSeverities(min) +} + +// ComponentMatchesSeverity returns true if the component's metrics contain at +// least one vulnerability of a severity present in `allowed` (or any +// severity when `allowed` is empty). Separated so callers can invoke it +// without reaching into the scainternal types. +func ComponentMatchesSeverity(hasCounts func(severity string) int, allowed []string) bool { + if len(allowed) == 0 { + return true + } + for _, sev := range allowed { + if hasCounts(sev) > 0 { + return true + } + } + return false +} diff --git a/pkg/cmd/sca/internal/validate_test.go b/pkg/cmd/sca/internal/validate_test.go new file mode 100644 index 0000000..fb7a0ed --- /dev/null +++ b/pkg/cmd/sca/internal/validate_test.go @@ -0,0 +1,245 @@ +package scainternal + +import ( + "errors" + "reflect" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestValidateCodebaseKey(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantErr string + }{ + {"valid", "my-service", ""}, + {"valid with digits", "svc-42", ""}, + {"empty", "", "must not be empty"}, + {"uppercase rejected", "MyService", "DNS-1123"}, + {"too long", strings.Repeat("x", 300), "at most"}, + {"invalid char", "my_service", "DNS-1123"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := ValidateCodebaseKey(tc.input) + switch { + case tc.wantErr == "" && err != nil: + t.Errorf("want nil, got %v", err) + case tc.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tc.wantErr)): + t.Errorf("want substr %q, got %v", tc.wantErr, err) + } + }) + } +} + +func TestValidateOutputFormat(t *testing.T) { + t.Parallel() + + for _, f := range []string{"", "table", "json"} { + if err := ValidateOutputFormat(f); err != nil { + t.Errorf("%q must be valid, got %v", f, err) + } + } + if err := ValidateOutputFormat("yaml"); err == nil { + t.Error("yaml must be rejected") + } +} + +func TestValidateCodebaseCommand_SkipsPRMutex(t *testing.T) { + t.Parallel() + + cmd := &cobra.Command{} + cmd.Flags().String("branch", "", "") + // no --pr flag on sca; helper must not complain about it. + + if err := ValidateCodebaseCommand(cmd, "", "svc", ""); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateCodebaseCommand_RejectsEmptyBranchWhenSupplied(t *testing.T) { + t.Parallel() + + cmd := &cobra.Command{} + cmd.Flags().String("branch", "", "") + if err := cmd.Flags().Set("branch", ""); err != nil { + t.Fatalf("set: %v", err) + } + // Simulate cobra marking the flag as changed (user typed --branch=""). + cmd.Flags().Changed("branch") // no-op; need to mark as changed directly. + f := cmd.Flags().Lookup("branch") + f.Changed = true + + err := ValidateCodebaseCommand(cmd, "", "svc", "") + if err == nil || !strings.Contains(err.Error(), "requires a non-empty value") { + t.Errorf("expected non-empty-value error, got %v", err) + } +} + +func TestValidateSeverityCSV(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input []string + want []string + wantErr bool + }{ + {"empty", nil, nil, false}, + {"single lowercase", []string{"critical"}, []string{"CRITICAL"}, false}, + {"uppercase accepted", []string{"HIGH"}, []string{"HIGH"}, false}, + {"mixed case", []string{"Medium"}, []string{"MEDIUM"}, false}, + {"csv in single value", []string{"low,info"}, []string{"LOW", "INFO"}, false}, + {"dedupe", []string{"low,LOW,Low"}, []string{"LOW"}, false}, + {"unassigned explicit", []string{"unassigned"}, []string{"UNASSIGNED"}, false}, + {"invalid", []string{"garbage"}, nil, true}, + {"one invalid in csv", []string{"critical,garbage"}, nil, true}, + {"empty csv tokens skipped", []string{"critical,,high"}, []string{"CRITICAL", "HIGH"}, false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := ValidateSeverityCSV(tc.input) + if tc.wantErr { + if err == nil { + t.Errorf("want error, got %v", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("got %v want %v", got, tc.want) + } + }) + } +} + +func TestValidateSeverityCSV_ErrorListsAccepted(t *testing.T) { + t.Parallel() + _, err := ValidateSeverityCSV([]string{"garbage"}) + if err == nil { + t.Fatal("expected error") + } + for _, wanted := range []string{"CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO", "UNASSIGNED"} { + if !strings.Contains(err.Error(), wanted) { + t.Errorf("error message should list %s; got %q", wanted, err.Error()) + } + } +} + +func TestInclusiveSeverities(t *testing.T) { + t.Parallel() + + tests := []struct { + min string + want []string + }{ + {"", nil}, + {"CRITICAL", []string{"CRITICAL"}}, + {"critical", []string{"CRITICAL"}}, + {"HIGH", []string{"CRITICAL", "HIGH"}}, + {"MEDIUM", []string{"CRITICAL", "HIGH", "MEDIUM"}}, + {"LOW", []string{"CRITICAL", "HIGH", "MEDIUM", "LOW"}}, + {"INFO", []string{"CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO", "UNASSIGNED"}}, + {"UNASSIGNED", []string{"CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO", "UNASSIGNED"}}, + {"unknown", nil}, + } + for _, tc := range tests { + t.Run(tc.min, func(t *testing.T) { + t.Parallel() + got := InclusiveSeverities(tc.min) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("got %v want %v", got, tc.want) + } + }) + } +} + +func TestInclusiveFromSet(t *testing.T) { + t.Parallel() + + if got := InclusiveFromSet(nil); got != nil { + t.Errorf("nil input → got %v", got) + } + + // Multi-value: take the least severe and expand from there. + got := InclusiveFromSet([]string{"CRITICAL", "MEDIUM"}) + want := []string{"CRITICAL", "HIGH", "MEDIUM"} + if !reflect.DeepEqual(got, want) { + t.Errorf("got %v want %v", got, want) + } + + // Single least-severe LOW must include LOW too. + got = InclusiveFromSet([]string{"LOW"}) + want = []string{"CRITICAL", "HIGH", "MEDIUM", "LOW"} + if !reflect.DeepEqual(got, want) { + t.Errorf("got %v want %v", got, want) + } +} + +func TestSeverityMatches(t *testing.T) { + t.Parallel() + if !SeverityMatches("CRITICAL", []string{"CRITICAL", "HIGH"}) { + t.Error("CRITICAL must match") + } + if SeverityMatches("LOW", []string{"CRITICAL", "HIGH"}) { + t.Error("LOW must not match") + } + if SeverityMatches("CRITICAL", nil) { + t.Error("empty allowed must not match") + } +} + +func TestComponentMatchesSeverity(t *testing.T) { + t.Parallel() + + // empty allowed → match anything. + if !ComponentMatchesSeverity(func(string) int { return 0 }, nil) { + t.Error("empty allowed must return true") + } + + counts := map[string]int{"CRITICAL": 0, "HIGH": 2, "MEDIUM": 0} + lookup := func(severity string) int { return counts[severity] } + + if !ComponentMatchesSeverity(lookup, []string{"CRITICAL", "HIGH"}) { + t.Error("must match when HIGH > 0") + } + if ComponentMatchesSeverity(lookup, []string{"CRITICAL"}) { + t.Error("must not match when CRITICAL=0") + } +} + +func TestConstants(t *testing.T) { + t.Parallel() + if MaxPageSize != 500 { + t.Errorf("MaxPageSize = %d; want 500", MaxPageSize) + } + if !strings.Contains(BranchFlagUsage, "Dep-Track project 'version'") { + t.Error("BranchFlagUsage must document the Dep-Track version mapping") + } + if !strings.Contains(SeverityFlagUsage, "inclusive") || !strings.Contains(SeverityFlagUsage, "UNASSIGNED") { + t.Error("SeverityFlagUsage must describe inclusivity and INFO/UNASSIGNED merge") + } +} + +// ensure we surface the validator error through errors.Is without wrapping. +func TestValidateOutputFormat_ErrorType(t *testing.T) { + t.Parallel() + err := ValidateOutputFormat("csv") + if err == nil { + t.Fatal("expected error") + } + // ValidateOutputFormat returns a fmt.Errorf; no sentinel check — just + // ensure it's a plain error. + if errors.Is(err, nil) { + t.Error("error should not be nil via errors.Is") + } +} diff --git a/pkg/cmd/sca/list/list.go b/pkg/cmd/sca/list/list.go new file mode 100644 index 0000000..63c8672 --- /dev/null +++ b/pkg/cmd/sca/list/list.go @@ -0,0 +1,142 @@ +// Package list implements the "krci sca list" command. +package list + +import ( + "context" + "fmt" + "io" + "time" + + "github.com/spf13/cobra" + + "github.com/KubeRocketCI/cli/internal/cmdutil" + "github.com/KubeRocketCI/cli/internal/iostreams" + "github.com/KubeRocketCI/cli/internal/output" + "github.com/KubeRocketCI/cli/internal/portal" + "github.com/KubeRocketCI/cli/internal/portal/restapi" + scainternal "github.com/KubeRocketCI/cli/pkg/cmd/sca/internal" +) + +// Default Dep-Track page size — matches Portal UI for projects. +const defaultListPageSize = 25 + +// ListOptions holds all inputs for `krci sca list`. +type ListOptions struct { + IO *iostreams.IOStreams + RestClient func() (*restapi.ClientWithResponses, error) + Page int + PageSize int + Search string + IncludeInactive bool + IncludeChildren bool + OutputFormat string +} + +// NewCmdList returns the "sca list" cobra.Command. +func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { + opts := &ListOptions{ + IO: f.IOStreams, + RestClient: f.RestClient, + } + + cmd := &cobra.Command{ + Use: "list", + Short: "List Dependency-Track SCA projects", + Long: `List Software Composition Analysis projects known to Dependency-Track. +By default excludes inactive projects and child versions.`, + Args: cobra.NoArgs, + Example: ` # First page (default 25), active root projects only + krci sca list + + # Paginate + search + krci sca list --search payments --page 2 --page-size 50 + + # Include inactive and child (non-root) projects + krci sca list --include-inactive --include-children + + # Scripting — every project with at least one CRITICAL finding + krci sca list -o json | jq -r '.data.items[] | select(.metrics.critical>0) | .name'`, + RunE: func(cmd *cobra.Command, _ []string) error { + if err := scainternal.ValidateOutputFormat(opts.OutputFormat); err != nil { + return err + } + + if err := scainternal.ValidatePageBounds(opts.Page, opts.PageSize); err != nil { + return err + } + + if runF != nil { + return runF(opts) + } + + return listRun(cmd.Context(), opts) + }, + } + + cmd.Flags().IntVar(&opts.Page, "page", 1, "Page index (1-based)") + cmd.Flags().IntVar(&opts.PageSize, "page-size", defaultListPageSize, "Page size (max 500)") + cmd.Flags().StringVar(&opts.Search, "search", "", "Filter projects by partial name / version") + cmd.Flags().BoolVar(&opts.IncludeInactive, "include-inactive", false, "Include inactive projects") + cmd.Flags().BoolVar(&opts.IncludeChildren, "include-children", false, "Include child (non-root) versions") + cmd.Flags().StringVarP(&opts.OutputFormat, "output", "o", "", + "Output format: table, json (default: table)") + + return cmd +} + +func listRun(ctx context.Context, opts *ListOptions) error { + client, err := opts.RestClient() + if err != nil { + return scainternal.HandleError(opts.IO, opts.OutputFormat, err) + } + + result, err := portal.NewSCAService(client).List(ctx, portal.SCAListParams{ + Page: opts.Page, + PageSize: opts.PageSize, + Search: opts.Search, + IncludeInactive: opts.IncludeInactive, + IncludeChildren: opts.IncludeChildren, + }) + if err != nil { + return scainternal.HandleError(opts.IO, opts.OutputFormat, err) + } + + return scainternal.Render(opts.IO, opts.OutputFormat, result, func(w io.Writer, isTTY bool) error { + return renderTable(w, isTTY, opts.Page, opts.PageSize, result) + }) +} + +func renderTable(w io.Writer, isTTY bool, page, pageSize int, result *portal.SCAProjectList) error { + headers := []string{"NAME", "VERSION", "CLASSIFIER", "ACTIVE", "LAST_BOM", "RISK", "VULNS (C/H/M/L)"} + rows := make([][]string, 0, len(result.Items)) + + for _, p := range result.Items { + rows = append(rows, []string{ + p.Name, + p.Version, + p.Classifier, + scainternal.BoolYesNo(p.Active), + formatLastBom(p.LastBomImport), + scainternal.FormatRisk(p.RiskScore), + scainternal.FormatVulnCounts(p.Metrics, isTTY), + }) + } + + if err := scainternal.PrintTable(w, isTTY, headers, rows); err != nil { + return err + } + + if _, err := fmt.Fprintln(w); err != nil { + return err + } + + _, err := fmt.Fprintln(w, scainternal.PageFooter("project", result.TotalCount, page, pageSize)) + return err +} + +func formatLastBom(ms int64) string { + if ms <= 0 { + return scainternal.EmptyPlaceholder + } + return output.RelativeTime(time.UnixMilli(ms)) +} diff --git a/pkg/cmd/sca/list/list_test.go b/pkg/cmd/sca/list/list_test.go new file mode 100644 index 0000000..85749a7 --- /dev/null +++ b/pkg/cmd/sca/list/list_test.go @@ -0,0 +1,96 @@ +package list + +import ( + "bytes" + "strings" + "testing" + + "github.com/KubeRocketCI/cli/pkg/cmd/sca/internal/scatestutil" +) + +func TestList_RejectsPositionalArgs(t *testing.T) { + t.Parallel() + + cmd := NewCmdList(scatestutil.NewFactory(), nil) + cmd.SetArgs([]string{"frobnicate"}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for stray positional arg") + } +} + +func TestList_RejectsUnknownOutputFormat(t *testing.T) { + t.Parallel() + + cmd := NewCmdList(scatestutil.NewFactory(), nil) + cmd.SetArgs([]string{"-o", "yaml"}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "unknown output format") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestList_PageBounds(t *testing.T) { + t.Parallel() + + cases := []struct { + args []string + want string + }{ + {[]string{"--page", "0"}, "--page must be >= 1"}, + {[]string{"--page-size", "0"}, "--page-size must be between 1 and 500"}, + {[]string{"--page-size", "1000"}, "--page-size must be between 1 and 500"}, + } + for _, tc := range cases { + cmd := NewCmdList(scatestutil.NewFactory(), nil) + cmd.SetArgs(tc.args) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("args %v: got %v, want substr %q", tc.args, err, tc.want) + } + } +} + +func TestList_RunFInjection(t *testing.T) { + t.Parallel() + + var captured *ListOptions + runF := func(o *ListOptions) error { + captured = o + return nil + } + + cmd := NewCmdList(scatestutil.NewFactory(), runF) + cmd.SetArgs([]string{"--page", "3", "--page-size", "50", "--search", "pay", + "--include-inactive", "--include-children", "-o", "json"}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute error: %v", err) + } + if captured == nil { + t.Fatal("runF not invoked") + } + if captured.Page != 3 || captured.PageSize != 50 || captured.Search != "pay" || + !captured.IncludeInactive || !captured.IncludeChildren || captured.OutputFormat != "json" { + t.Errorf("captured: %+v", captured) + } +} + +func TestFormatLastBom_ZeroIsDash(t *testing.T) { + t.Parallel() + + if got := formatLastBom(0); got != "—" { + t.Errorf("formatLastBom(0) = %q; want dash", got) + } +} diff --git a/pkg/cmd/sca/sca.go b/pkg/cmd/sca/sca.go new file mode 100644 index 0000000..a6c5a7d --- /dev/null +++ b/pkg/cmd/sca/sca.go @@ -0,0 +1,34 @@ +// Package sca implements the "krci sca" command group (Software Composition +// Analysis views backed by Dependency-Track). +package sca + +import ( + "github.com/spf13/cobra" + + "github.com/KubeRocketCI/cli/internal/cmdutil" + "github.com/KubeRocketCI/cli/pkg/cmd/sca/components" + "github.com/KubeRocketCI/cli/pkg/cmd/sca/findings" + "github.com/KubeRocketCI/cli/pkg/cmd/sca/get" + "github.com/KubeRocketCI/cli/pkg/cmd/sca/list" +) + +// NewCmdSca returns the "sca" group cobra.Command with all subcommands attached. +func NewCmdSca(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "sca", + Short: "Inspect Dependency-Track projects, components, and vulnerability findings", + Long: `Inspect Software Composition Analysis (SCA) state — projects, dependencies, +and vulnerability findings — without leaving the terminal. Uses the +Dependency-Track binding already configured on the Portal +(DEPENDENCY_TRACK_URL / DEPENDENCY_TRACK_API_KEY).`, + } + + cmd.AddCommand( + list.NewCmdList(f, nil), + get.NewCmdGet(f, nil), + components.NewCmdComponents(f, nil), + findings.NewCmdFindings(f, nil), + ) + + return cmd +} diff --git a/pkg/cmd/sca/sca_test.go b/pkg/cmd/sca/sca_test.go new file mode 100644 index 0000000..1d9aa1a --- /dev/null +++ b/pkg/cmd/sca/sca_test.go @@ -0,0 +1,40 @@ +package sca + +import ( + "testing" + + "github.com/KubeRocketCI/cli/internal/cmdutil" + "github.com/KubeRocketCI/cli/internal/iostreams" +) + +func TestNewCmdSca(t *testing.T) { + t.Parallel() + + f := &cmdutil.Factory{IOStreams: &iostreams.IOStreams{}} + cmd := NewCmdSca(f) + + if cmd.Use != "sca" { + t.Errorf("Use = %q", cmd.Use) + } + if cmd.Short == "" { + t.Error("Short must not be empty") + } + if cmd.Long == "" { + t.Error("Long must not be empty") + } + + want := map[string]bool{"list": false, "get": false, "components": false, "findings": false} + for _, sub := range cmd.Commands() { + if _, ok := want[sub.Name()]; ok { + want[sub.Name()] = true + } + } + for name, seen := range want { + if !seen { + t.Errorf("missing subcommand %q", name) + } + } + if got := len(cmd.Commands()); got != 4 { + t.Errorf("want 4 subcommands, got %d", got) + } +}