Skip to content

Commit 5d4c47c

Browse files
committed
feat(config): add org quality gate enforcement and eol policy
Add an org-wide Quality Gate that the CLI fetches and applies during authenticated scans: - config set/get quality-gate: 9 nullable enforcement settings (block-eol, block-malware, block-unpinned, cooldown, version-lag, sca-autofix-max-major-bump, sca-autofix-strategy, exploits, severity) - config set/get eol-policy: 4 EOL calendar-quarter severity buckets - scan resolves the org policy and overrides matching flags before the gate is evaluated; org policy always wins, even over an explicit flag, and --verbose announces each applied/superseded setting - pass "null" to any quality-gate flag to unset that setting for the org - docs: cli-reference config section + enterprise/quality-gates guide Also point the upload client at the /v2/cli.upload endpoint.
1 parent be0f7b3 commit 5d4c47c

9 files changed

Lines changed: 841 additions & 2 deletions

File tree

cmd/config.go

Lines changed: 434 additions & 0 deletions
Large diffs are not rendered by default.

cmd/quality_gate_enforce.go

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
package cmd
2+
3+
// Scan-time org quality-gate enforcement override.
4+
//
5+
// When a scan runs under a real (non-community) authenticated org, the org's
6+
// stored quality-gate policy is fetched once and applied over the scan's
7+
// control flags. The decided semantics are: ORG POLICY ALWAYS WINS — a set org
8+
// value overrides even an explicitly-passed CLI flag. A setting the org left
9+
// null leaves the caller's flag (or builtin default) untouched. Verbose output
10+
// notes every supersede / application; non-authenticated scans use only the
11+
// CLI flags (this function returns early).
12+
13+
import (
14+
"fmt"
15+
"os"
16+
17+
"github.com/spf13/cobra"
18+
"github.com/vulnetix/cli/v3/pkg/auth"
19+
"github.com/vulnetix/cli/v3/pkg/vdb"
20+
)
21+
22+
// qualityGateOverridePointers bundles pointers to the nine scan-time control
23+
// locals in runScanWithFeatures so applyOrgQualityGate can overwrite each in
24+
// place when the org enforces a value. Field names mirror the camelCase config
25+
// keys returned by /v2/cli.quality-gate-get.
26+
type qualityGateOverridePointers struct {
27+
blockEol *bool
28+
blockMalware *bool
29+
blockUnpinned *bool
30+
cooldown *int
31+
versionLag *int
32+
scaAutofixMaxMajorBump *int
33+
exploits *string
34+
severity *string
35+
scaAutofixStrategy *string
36+
}
37+
38+
// applyOrgQualityGate fetches the authenticated org's quality-gate policy and
39+
// applies every set (non-null) enforcement value over the caller's scan flags.
40+
// It is a no-op (caller/builtin values stand) when the scan is unauthenticated
41+
// or community-tier, when credentials cannot be loaded, when the lookup fails,
42+
// or when the org has no policy row. All diagnostic output is gated on the
43+
// existing --verbose flag.
44+
func applyOrgQualityGate(cmd *cobra.Command, p qualityGateOverridePointers) {
45+
creds, err := auth.LoadCredentials()
46+
if err != nil || creds == nil || auth.IsCommunity(creds) {
47+
if verbose {
48+
fmt.Fprintln(os.Stderr, "Org quality gate: skipped (no authenticated org — using scan flags only).")
49+
}
50+
return
51+
}
52+
53+
client := vdb.NewClientFromCredentials(creds)
54+
client.APIVersion = "/v2"
55+
client.BaseURL = vdb.DefaultBaseURL
56+
if f := cmd.Flags().Lookup("base-url"); f != nil {
57+
if baseURL, _ := cmd.Flags().GetString("base-url"); baseURL != "" {
58+
client.BaseURL = baseURL
59+
}
60+
}
61+
62+
resp, err := client.CliQualityGateGet(envForCli())
63+
if err != nil {
64+
if verbose {
65+
fmt.Fprintf(os.Stderr, "Org quality gate: lookup failed (%v) — using scan flags only.\n", err)
66+
}
67+
return
68+
}
69+
70+
config, _ := resp.Data["config"].(map[string]any)
71+
if config == nil {
72+
if verbose {
73+
fmt.Fprintln(os.Stderr, "Org quality gate: no policy configured — using scan flags only.")
74+
}
75+
return
76+
}
77+
78+
applied := 0
79+
80+
applyBool := func(flag, key string, target *bool) {
81+
orgVal, ok := qgConfigBool(config, key)
82+
if !ok {
83+
return
84+
}
85+
callerVal := *target
86+
*target = orgVal
87+
applied++
88+
noteOverride(cmd, flag, fmt.Sprintf("%t", callerVal), fmt.Sprintf("%t", orgVal))
89+
}
90+
applyInt := func(flag, key string, target *int) {
91+
orgVal, ok := qgConfigInt(config, key)
92+
if !ok {
93+
return
94+
}
95+
callerVal := *target
96+
*target = orgVal
97+
applied++
98+
noteOverride(cmd, flag, fmt.Sprintf("%d", callerVal), fmt.Sprintf("%d", orgVal))
99+
}
100+
applyString := func(flag, key string, target *string) {
101+
orgVal, ok := qgConfigString(config, key)
102+
if !ok {
103+
return
104+
}
105+
callerVal := *target
106+
*target = orgVal
107+
applied++
108+
noteOverride(cmd, flag, callerVal, orgVal)
109+
}
110+
111+
applyBool("block-eol", "blockEol", p.blockEol)
112+
applyBool("block-malware", "blockMalware", p.blockMalware)
113+
applyBool("block-unpinned", "blockUnpinned", p.blockUnpinned)
114+
applyInt("cooldown", "cooldown", p.cooldown)
115+
applyInt("version-lag", "versionLag", p.versionLag)
116+
applyInt("sca-autofix-max-major-bump", "scaAutofixMaxMajorBump", p.scaAutofixMaxMajorBump)
117+
applyString("exploits", "exploits", p.exploits)
118+
applyString("severity", "severity", p.severity)
119+
applyString("sca-autofix-strategy", "scaAutofixStrategy", p.scaAutofixStrategy)
120+
121+
if applied > 0 && verbose {
122+
fmt.Fprintf(os.Stderr, "Org quality gate: applied %s from org policy (org policy always wins).\n",
123+
pluralise("setting", applied))
124+
}
125+
}
126+
127+
// noteOverride emits the verbose supersede/applied line for one enforcement
128+
// field. When the caller explicitly set the flag and the org value differs, it
129+
// notes the supersede; when the caller did not set it, it notes the org policy
130+
// application. Output is gated on --verbose. callerVal/orgVal are already
131+
// stringified by the caller.
132+
func noteOverride(cmd *cobra.Command, flag, callerVal, orgVal string) {
133+
if !verbose {
134+
return
135+
}
136+
if cmd.Flags().Changed(flag) {
137+
if callerVal != orgVal {
138+
fmt.Fprintf(os.Stderr, "--%s %s superseded by org policy: %s\n", flag, callerVal, orgVal)
139+
}
140+
return
141+
}
142+
fmt.Fprintf(os.Stderr, "org policy applied: --%s %s\n", flag, orgVal)
143+
}
144+
145+
// qgConfigBool / qgConfigInt / qgConfigString read one nullable enforcement
146+
// field from the cli.quality-gate-get config map. The second return is false
147+
// when the org left the field null (absent or JSON null), so the caller leaves
148+
// its local untouched. JSON numbers decode as float64.
149+
func qgConfigBool(m map[string]any, key string) (bool, bool) {
150+
v, ok := m[key].(bool)
151+
return v, ok
152+
}
153+
154+
func qgConfigInt(m map[string]any, key string) (int, bool) {
155+
if v, ok := m[key].(float64); ok {
156+
return int(v), true
157+
}
158+
return 0, false
159+
}
160+
161+
func qgConfigString(m map[string]any, key string) (string, bool) {
162+
if v, ok := m[key].(string); ok && v != "" {
163+
return v, true
164+
}
165+
return "", false
166+
}

cmd/scan.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,25 @@ func runScanWithFeatures(ctx context.Context, cmd *cobra.Command, noSAST, noSCA,
381381
scaAutofixMaxMajorBump, _ := cmd.Flags().GetInt("sca-autofix-max-major-bump")
382382
yes, _ := cmd.Flags().GetBool("yes")
383383
pathExplicit := cmd.Flags().Changed("path")
384+
385+
// Org quality-gate enforcement override. Applied after all nine control
386+
// flags are read but BEFORE any is consumed (sca-autofix strategy parsing
387+
// below, runLocalScan, and the gate options). For an authenticated org with
388+
// a quality-gate policy, a set org value overwrites the local in place —
389+
// org policy always wins, even over an explicitly-passed flag. Unauthenticated
390+
// / community scans and orgs without a policy leave these values untouched.
391+
applyOrgQualityGate(cmd, qualityGateOverridePointers{
392+
blockEol: &blockEOL,
393+
blockMalware: &blockMalware,
394+
blockUnpinned: &blockUnpinned,
395+
cooldown: &cooldownDays,
396+
versionLag: &versionLag,
397+
scaAutofixMaxMajorBump: &scaAutofixMaxMajorBump,
398+
exploits: &exploitThreshold,
399+
severity: &severityThreshold,
400+
scaAutofixStrategy: &scaAutofixStrategyRaw,
401+
})
402+
384403
scaAutofixStrategy, err := autofix.ValidateStrategy(scaAutofixStrategyRaw)
385404
if err != nil {
386405
return err

internal/upload/client.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ func (c *Client) MultipartUploadWithProgress(fileName string, data []byte, conte
204204
return nil, fmt.Errorf("failed to close multipart body: %w", err)
205205
}
206206

207-
req, err := http.NewRequest("POST", c.BaseURL+"/uploads", &buf)
207+
req, err := http.NewRequest("POST", strings.TrimSuffix(c.BaseURL, "/v1") + "/v2/cli.upload", &buf)
208208
if err != nil {
209209
return nil, fmt.Errorf("failed to create upload request: %w", err)
210210
}

pkg/vdb/api_cli.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,31 @@ type CliPackageFirewallMirrorRequest struct {
492492
IsActive *bool `json:"isActive,omitempty"`
493493
}
494494

495+
type CliQualityGateConfigRequest struct {
496+
EolNextQuarterSeverity *string `json:"eolNextQuarterSeverity,omitempty"`
497+
EolThisQuarterSeverity *string `json:"eolThisQuarterSeverity,omitempty"`
498+
EolWithin30DaysSeverity *string `json:"eolWithin30DaysSeverity,omitempty"`
499+
EolRetiredSeverity *string `json:"eolRetiredSeverity,omitempty"`
500+
501+
// Org-wide CLI scan enforcement settings (all nullable — a nil pointer means
502+
// the org did not set this, so the caller's flag / builtin default stands).
503+
// The upsert merges on conflict (COALESCE), so omitted fields preserve their
504+
// stored value; clearing a value is done via the website full-replace PUT.
505+
BlockEol *bool `json:"blockEol,omitempty"`
506+
BlockMalware *bool `json:"blockMalware,omitempty"`
507+
BlockUnpinned *bool `json:"blockUnpinned,omitempty"`
508+
Cooldown *int `json:"cooldown,omitempty"`
509+
VersionLag *int `json:"versionLag,omitempty"`
510+
ScaAutofixMaxMajorBump *int `json:"scaAutofixMaxMajorBump,omitempty"`
511+
Exploits *string `json:"exploits,omitempty"`
512+
Severity *string `json:"severity,omitempty"`
513+
ScaAutofixStrategy *string `json:"scaAutofixStrategy,omitempty"`
514+
515+
// Clear lists camelCase enforcement field names to reset to NULL (unset) for
516+
// the org — distinct from an omitted field, which preserves its stored value.
517+
Clear []string `json:"clear,omitempty"`
518+
}
519+
495520
// ─── Env snapshot ────────────────────────────────────────────────────────
496521

497522
// SnapshotEnv assembles the CliEnv block from the running CLI process. Safe
@@ -774,6 +799,20 @@ func (c *Client) CliPackageFirewallGet(env CliEnv) (*CliResponse[map[string]any]
774799
return cliPostWithEnv[map[string]any](c, "cli.package-firewall-get", env, struct{}{})
775800
}
776801

802+
// CliQualityGateConfig — POST /v2/cli.quality-gate-config. Upserts the org's
803+
// EOL-severity quality-gate policy. Only changed fields are sent (pointer
804+
// fields nil otherwise), so an unset severity preserves its current value.
805+
func (c *Client) CliQualityGateConfig(env CliEnv, req CliQualityGateConfigRequest) (*CliResponse[map[string]any], error) {
806+
return cliPostWithEnv[map[string]any](c, "cli.quality-gate-config", env, req)
807+
}
808+
809+
// CliQualityGateGet — POST /v2/cli.quality-gate-get. Read-only: returns the
810+
// org's quality-gate policy ({"config": {...}|null}). Org is resolved from the
811+
// authenticated request, so the payload is empty.
812+
func (c *Client) CliQualityGateGet(env CliEnv) (*CliResponse[map[string]any], error) {
813+
return cliPostWithEnv[map[string]any](c, "cli.quality-gate-get", env, struct{}{})
814+
}
815+
777816
// SARIF-shaped scan endpoints. Each returns the same persistence response
778817
// (IngestionSnapshot + Findings + Stats) so the CLI's snapshot-URL output is
779818
// uniform across kinds.

website/content/docs/cli-reference/_index.md

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,11 +128,15 @@ This command writes a `machine packages.vulnetix.com` entry to `.netrc`, persist
128128

129129
### vulnetix config
130130

131-
Manage Vulnetix configuration. Today this manages the [Package Firewall](/docs/enterprise/package-firewall/policies/) per-organization policy and ecosystem mirrors. The organization is resolved from your authenticated session (`vulnetix auth login`).
131+
Manage Vulnetix configuration. This manages the [Package Firewall](/docs/enterprise/package-firewall/policies/) per-organization policy and ecosystem mirrors, the org-wide [Quality Gate](/docs/enterprise/quality-gates/) scan-enforcement policy, and the Quality Gate end-of-life severity buckets. The organization is resolved from your authenticated session (`vulnetix auth login`).
132132

133133
```bash
134134
vulnetix config set package-firewall [ecosystem] [url] [flags]
135135
vulnetix config get package-firewall [flags]
136+
vulnetix config set quality-gate [flags]
137+
vulnetix config get quality-gate [flags]
138+
vulnetix config set eol-policy [flags]
139+
vulnetix config get eol-policy [flags]
136140
```
137141

138142
#### config set package-firewall
@@ -194,6 +198,87 @@ vulnetix config get package-firewall -o json
194198
| `--base-url` | string | `https://api.vdb.vulnetix.com` | VDB API base URL |
195199
| `-o, --output` | string | `pretty` | Output format: `pretty`, `json` |
196200

201+
#### config set quality-gate
202+
203+
Set the org-wide [Quality Gate](/docs/enterprise/quality-gates/) scan-enforcement policy. When a member runs `vulnetix scan` (or `sca`, `sast`, …) while authenticated, every value you set here **overrides** the equivalent scan flag — **org policy always wins**, even over an explicitly-passed flag. Settings you leave unset fall back to the caller's flag or the builtin default.
204+
205+
Each call is a **partial update** — only the flags you pass change; everything else keeps its current value. To clear a setting back to "not enforced", pass `null` as the flag's value (e.g. `--severity null`) — members then fall back to their own scan flag or the builtin default.
206+
207+
```bash
208+
vulnetix config set quality-gate --severity high --block-malware true --cooldown 3
209+
```
210+
211+
| Flag | Value | Description |
212+
|------|-------|-------------|
213+
| `--block-eol` | `true\|false\|null` | Exit `1` when a runtime or package dependency is end-of-life |
214+
| `--block-malware` | `true\|false\|null` | Exit `1` when any dependency is a known malicious package |
215+
| `--block-unpinned` | `true\|false\|null` | Exit `1` when any direct dependency uses a version range instead of an exact pin |
216+
| `--cooldown` | `≥ 0 \| null` | Exit `1` when any dependency version was published within the last *n* days (`0` disables) |
217+
| `--version-lag` | `≥ 0 \| null` | Exit `1` when any dependency is within the *n* most recently published versions (`0` disables) |
218+
| `--sca-autofix-max-major-bump` | `≥ 0 \| null` | Refuse autofix targets crossing more than *n* major versions |
219+
| `--exploits` | `poc\|active\|weaponized\|null` | Exit `1` when exploit maturity reaches the threshold |
220+
| `--severity` | `low\|medium\|high\|critical\|null` | Exit `1` when any vulnerability or SAST finding meets or exceeds this level |
221+
| `--sca-autofix-strategy` | `latest\|safest\|stable\|null` | Target strategy for `--sca-autofix` |
222+
223+
Every flag takes a value (e.g. `--block-malware true`). Pass **`null`** to unset a setting entirely for the org — the value is cleared and members fall back to their own scan flag or the builtin default:
224+
225+
```bash
226+
vulnetix config set quality-gate --severity null --cooldown null
227+
```
228+
229+
Omitting a flag leaves its stored value unchanged. Both this command and `config get quality-gate` share `--base-url` (default `https://api.vdb.vulnetix.com`) and `-o, --output` (`pretty`, `json`).
230+
231+
#### config get quality-gate
232+
233+
Print the org-wide Quality Gate enforcement policy. Settings the organization never configured render as **not set** (the caller's flag or builtin default applies for those).
234+
235+
```bash
236+
vulnetix config get quality-gate
237+
vulnetix config get quality-gate -o json
238+
```
239+
240+
| Flag | Type | Default | Description |
241+
|------|------|---------|-------------|
242+
| `--base-url` | string | `https://api.vdb.vulnetix.com` | VDB API base URL |
243+
| `-o, --output` | string | `pretty` | Output format: `pretty`, `json` |
244+
245+
#### config set eol-policy
246+
247+
Set the four end-of-life **calendar-quarter severity buckets** of the Quality Gate. These map an upcoming or past EOL date to a synthetic finding severity during `vulnetix scan` (opt-in via the org policy). The buckets are literal calendar quarters (Q1 Jan–Mar, Q2 Apr–Jun, Q3 Jul–Sep, Q4 Oct–Dec); a date is classified by which quarter it lands in. This is **not** a per-product mapping — it is the four shared time buckets.
248+
249+
Each call is a **partial update**.
250+
251+
```bash
252+
vulnetix config set eol-policy \
253+
--next-quarter-severity low \
254+
--this-quarter-severity medium \
255+
--within-30-days-severity high \
256+
--retired-severity critical
257+
```
258+
259+
| Flag | Type | Value | Description |
260+
|------|------|-------|-------------|
261+
| `--next-quarter-severity` | string | `skip\|low\|medium\|high\|critical` | Severity for products reaching EOL in the next calendar quarter |
262+
| `--this-quarter-severity` | string | `skip\|low\|medium\|high\|critical` | Severity for products reaching EOL in the current calendar quarter |
263+
| `--within-30-days-severity` | string | `skip\|low\|medium\|high\|critical` | Severity for products reaching EOL within the next 30 days |
264+
| `--retired-severity` | string | `skip\|low\|medium\|high\|critical` | Severity for products already past EOL (retired) |
265+
266+
Use `skip` to suppress findings for a bucket entirely. Both this command and `config get eol-policy` share `--base-url` (default `https://api.vdb.vulnetix.com`) and `-o, --output` (`pretty`, `json`).
267+
268+
#### config get eol-policy
269+
270+
Print the four EOL calendar-quarter severity buckets.
271+
272+
```bash
273+
vulnetix config get eol-policy
274+
vulnetix config get eol-policy -o json
275+
```
276+
277+
| Flag | Type | Default | Description |
278+
|------|------|---------|-------------|
279+
| `--base-url` | string | `https://api.vdb.vulnetix.com` | VDB API base URL |
280+
| `-o, --output` | string | `pretty` | Output format: `pretty`, `json` |
281+
197282
---
198283

199284
### vulnetix upload

website/content/docs/cli-reference/scan.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -760,6 +760,18 @@ vulnetix scan status abc123def
760760
vulnetix scan status abc123def --poll --poll-interval 10
761761
```
762762

763+
## Org Quality Gate Policy
764+
765+
When you run a scan while authenticated and your organization has configured a [Quality Gate](/docs/enterprise/quality-gates/), its settings are pulled in before the gate is evaluated and **override the matching scan flag defaults — org policy always wins**, even over a flag you pass explicitly. The override applies to `--severity`, `--block-eol`, `--block-malware`, `--block-unpinned`, `--exploits`, `--version-lag`, `--cooldown`, `--sca-autofix-strategy`, and `--sca-autofix-max-major-bump`. Settings the org left unset fall back to your flag or the builtin default.
766+
767+
Run with `--verbose` to see which settings were applied or superseded. Inspect the active policy with:
768+
769+
```bash
770+
vulnetix config get quality-gate
771+
```
772+
773+
Non-authenticated scans (no credentials, or the community fallback) have no organization to read from and use only the CLI flags you pass.
774+
763775
## Exit Codes
764776

765777
| Code | Meaning |

website/content/docs/enterprise/_index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,6 @@ Enterprise features for corporate environments, distribution, and advanced deplo
88
{{< cards >}}
99
{{< card link="corporate-proxy" title="Corporate Proxy" subtitle="Proxy servers, firewalls, and restricted networks." >}}
1010
{{< card link="package-firewall" title="Package Firewall" subtitle="Proxy and policy-enforce dependencies across 21 ecosystems — npm, PyPI, Cargo, Go, Maven, Docker/OCI and more." >}}
11+
{{< card link="quality-gates" title="Quality Gates" subtitle="Org-wide scan enforcement — severity, exploits, EOL, unpinned deps, SCA autofix." >}}
1112
{{< card link="publishing" title="Publishing & Distribution" subtitle="How Vulnetix CLI is published and distributed." >}}
1213
{{< /cards >}}

0 commit comments

Comments
 (0)