[Frontend]: Add KEV filter rendering to a policy drawer

Why are we doing this work

See parent epic for more details

  1. Design #556415 (closed)

Non-functional requirements

  • Documentation:
  • Feature flag: security_policies_kev_filtering
  • Performance:
  • Testing: Unit tests

Implementation plan

Context

With the securityPoliciesKevFilter feature flag enabled, scan result policy rules now store scanners as objects (e.g. { type: 'sast', vulnerabilities_allowed: 0, severity_levels: [...], vulnerability_states: [...], vulnerability_attributes: { known_exploited: true, epss_score: { operator: '>=', value: 50 } } }) instead of plain strings (e.g. 'sast'). Each scanner object carries its own per-scanner filter settings, while the rule-level properties (severity_levels, vulnerability_states, vulnerability_age, vulnerability_attributes) serve as global settings that apply across all scanners.

The policy drawer currently only handles the legacy string-based scanner format. We need to update it to render:

  1. A global settings paragraph for rule-level criteria (severity, status, age, attributes)
  2. A per-scanner paragraph for each scanner object's individual criteria

Key files

File Role
ee/app/assets/javascripts/security_orchestration/components/policy_drawer/scan_result/utils.js Humanization logic (humanizeRule, humanizeRules, humanizeScanners)
ee/app/assets/javascripts/security_orchestration/components/policy_drawer/scan_result/details_drawer.vue Template rendering of humanized rules
ee/app/assets/javascripts/security_orchestration/components/policy_editor/utils.js createHumanizedScanners() — maps scanner keys to display names
ee/app/assets/javascripts/security_orchestration/components/policy_editor/scan_result/rule/scanners/global_settings.vue Reference for global settings structure
ee/spec/frontend/security_orchestration/components/policy_drawer/scan_result/utils_spec.js Utils tests
ee/spec/frontend/security_orchestration/components/policy_drawer/scan_result/details_drawer_spec.js Drawer component tests

Scanner data structure (new format)

rules:
  - type: scan_finding
    branch_type: protected
    # Global settings (rule-level) — apply to all scanners
    severity_levels: [critical, high]
    vulnerability_states: [newly_detected]
    vulnerability_age: { operator: greater_than, value: 30, interval: day }
    vulnerability_attributes: { fix_available: true }
    vulnerabilities_allowed: 0
    # Per-scanner settings — each scanner is an object
    scanners:
      - type: dependency_scanning
        vulnerabilities_allowed: 5
        severity_levels: [critical]
        vulnerability_attributes:
          known_exploited: true
          epss_score: { operator: '>=', value: 50 }
      - type: sast
        vulnerabilities_allowed: 0
        severity_levels: []
        vulnerability_states: []

Humanized output example

Given this YAML input:

rules:
  - type: scan_finding
    branch_type: default
    scanners:
      - type: sast
        vulnerabilities_allowed: 0
        severity_levels: [critical, high]
        vulnerability_states: [newly_detected]
        vulnerability_attributes:
          false_positive: false
      - type: secret_detection
        vulnerabilities_allowed: 0
        severity_levels: [critical, high]
        vulnerability_states: [newly_detected]
      - type: dependency_scanning
        vulnerabilities_allowed: 0
        severity_levels: [critical, high]
        vulnerability_states: [newly_detected]
        vulnerability_attributes:
          false_positive: false
          fix_available: true
          known_exploited: true
          epss_score: { operator: greater_than_or_equal, value: 0.1 }
      - type: container_scanning
        vulnerabilities_allowed: 0
        severity_levels: [critical, high]
        vulnerability_states: [newly_detected]
        vulnerability_attributes:
          false_positive: false
          fix_available: true
          known_exploited: true
          epss_score: { operator: greater_than_or_equal, value: 0.1 }

The drawer should render:

When SAST, Secret Detection, Dependency Scanning or Container Scanning scanners find any vulnerability in an open merge request targeting the default branch.

SAST

  • Severity is critical or high.
  • Vulnerabilities are newly detected and need triage.
  • Vulnerabilities are not false positives.

Secret Detection

  • Severity is critical or high.
  • Vulnerabilities are newly detected and need triage.

Dependency Scanning

  • Severity is critical or high.
  • Vulnerabilities are newly detected and need triage.
  • Vulnerabilities are not false positives and have a fix available.
  • Vulnerabilities are in the KEV catalog.
  • Vulnerabilities have EPSS score >= 0.1.

Container Scanning

  • Severity is critical or high.
  • Vulnerabilities are newly detected and need triage.
  • Vulnerabilities are not false positives and have a fix available.
  • Vulnerabilities are in the KEV catalog.
  • Vulnerabilities have EPSS score >= 0.1.

The corresponding humanizeRule return object for this rule would be:

{
  summary: 'When SAST, Secret Detection, Dependency Scanning or Container Scanning scanners find any vulnerability in an open merge request targeting the default branch.',
  criteriaList: [],       // no rule-level global criteria (all criteria are per-scanner)
  criteriaMessage: '',
  branchExceptions: [],
  scannerDetails: [
    {
      name: 'SAST',
      criteriaList: [
        'Severity is critical or high.',
        'Vulnerabilities are newly detected and need triage.',
        'Vulnerabilities are not false positives.',
      ],
    },
    {
      name: 'Secret Detection',
      criteriaList: [
        'Severity is critical or high.',
        'Vulnerabilities are newly detected and need triage.',
      ],
    },
    {
      name: 'Dependency Scanning',
      criteriaList: [
        'Severity is critical or high.',
        'Vulnerabilities are newly detected and need triage.',
        'Vulnerabilities are not false positives and have a fix available.',
        'Vulnerabilities are in the KEV catalog.',
        'Vulnerabilities have EPSS score >= 0.1.',
      ],
    },
    {
      name: 'Container Scanning',
      criteriaList: [
        'Severity is critical or high.',
        'Vulnerabilities are newly detected and need triage.',
        'Vulnerabilities are not false positives and have a fix available.',
        'Vulnerabilities are in the KEV catalog.',
        'Vulnerabilities have EPSS score >= 0.1.',
      ],
    },
  ],
}

Note: When rule-level global settings (e.g., severity_levels, vulnerability_states) are also present alongside per-scanner overrides, they render in the criteriaList at the top level (before scanner details), and individual scanners only show their own overrides.

Tasks

1. Update createHumanizedScanners to handle scanner objects

File: ee/app/assets/javascripts/security_orchestration/components/policy_editor/utils.js

The current function only handles strings:

export const createHumanizedScanners = (scanners = []) =>
  scanners.map((scanner) => {
    return RULE_MODE_SCANNERS[scanner] || scanner;
  });

Update to handle both formats:

export const createHumanizedScanners = (scanners = []) =>
  scanners.map((scanner) => {
    const key = typeof scanner === 'object' ? scanner.type : scanner;
    return RULE_MODE_SCANNERS[key] || key;
  });

This ensures humanizeScanners() in the drawer still produces correct scanner name strings like "SAST", "Dependency Scanning" regardless of input format.

2. Update humanizeRule for the SCAN_FINDING branch to support per-scanner rendering

File: ee/app/assets/javascripts/security_orchestration/components/policy_drawer/scan_result/utils.js

When the scanners are objects (new format), humanizeRule should return additional data:

  • globalCriteriaList — criteria derived from the rule-level properties (current criteriaList behavior: severity, vulnerability states, age, attributes)
  • scannerDetails — an array of per-scanner humanized objects, each containing:
    • name — human-readable scanner name (e.g. "Dependency Scanning")
    • criteriaList — array of per-scanner criteria strings (vulnerabilities allowed, severity, vulnerability attributes like known_exploited, epss_score, etc.)

Detect object scanners via:

const hasObjectScanners = rule.scanners?.some((s) => typeof s === 'object');

When hasObjectScanners is true, build per-scanner criteria. Each scanner object can have:

  • vulnerabilities_allowed — humanize as "N vulnerabilities allowed"
  • severity_levels — humanize as "Severity is critical, high"
  • vulnerability_states — humanize as "Vulnerabilities are newly detected"
  • vulnerability_age — humanize age
  • vulnerability_attributes — humanize attributes including new ones:
    • known_exploited: true → "are in the KEV catalog" / false → "are not in the KEV catalog"
    • epss_score: { operator: '>=', value: 50 } → "have EPSS score >= 50"
    • fix_available, false_positive (already supported)

The returned object shape should change to include:

{
  summary: '...',           // same as before (scanner names + branch text)
  criteriaList: [...],      // global criteria from rule-level props
  criteriaMessage: '...',   // same as before
  branchExceptions: [...],  // same as before
  scannerDetails: [         // NEW: per-scanner criteria
    { name: 'Dependency Scanning', criteriaList: ['Severity is critical.', ...] },
    { name: 'SAST', criteriaList: [] },
  ],
}

When scanners are plain strings (legacy format), scannerDetails should be undefined/absent, preserving backward compatibility.

3. Add humanization for known_exploited and epss_score vulnerability attributes

File: ee/app/assets/javascripts/security_orchestration/components/policy_drawer/scan_result/utils.js

The existing humanizeVulnerabilityAttributes() (line 157) only handles fix_available and false_positive. Extend the sentenceMap to include:

const sentenceMap = {
  [FIX_AVAILABLE]: new Map([...]),
  [FALSE_POSITIVE]: new Map([...]),
  [KNOWN_EXPLOITED]: new Map([
    [true, s__('SecurityOrchestration|are in the KEV catalog')],
    [false, s__('SecurityOrchestration|are not in the KEV catalog')],
  ]),
};

Add a separate handler for epss_score (since it's not a boolean but an object { operator, value }):

if (vulnerabilityAttributes.epss_score) {
  const { operator, value } = vulnerabilityAttributes.epss_score;
  sentence.push(sprintf(s__('SecurityOrchestration|have EPSS score %{operator} %{value}'), { operator, value }));
}

Import KNOWN_EXPLOITED and EPSS_SCORE from the scan_filters constants.

4. Update the details_drawer.vue template to render per-scanner details

File: ee/app/assets/javascripts/security_orchestration/components/policy_drawer/scan_result/details_drawer.vue

Update the humanized rule iteration (lines 181-220) to also destructure and render scannerDetails:

<div
  v-for="(
    { summary, branchExceptions, licenses, criteriaMessage, criteriaList, denyAllowList, scannerDetails },
    idx
  ) in humanizedRules"
  :key="idx"
  class="gl-pt-5"
>
  <!-- existing summary, licenses, branch exceptions rendering -->
  <gl-sprintf :message="summary"> ... </gl-sprintf>
  ...

  <!-- Global criteria (rule-level) -->
  <p v-if="criteriaMessage">{{ capitalizedCriteriaMessage(criteriaMessage) }}</p>
  <ul class="gl-m-0">
    <li v-for="(criteria, criteriaIdx) in criteriaList" :key="criteriaIdx" class="gl-mt-2">
      {{ criteria }}
    </li>
  </ul>

  <!-- Per-scanner criteria (NEW) -->
  <div v-if="scannerDetails" class="gl-mt-3">
    <div v-for="(scanner, scannerIdx) in scannerDetails" :key="scannerIdx" class="gl-mt-3">
      <p class="gl-mb-2 gl-font-bold">{{ scanner.name }}</p>
      <ul v-if="scanner.criteriaList.length" class="gl-m-0">
        <li
          v-for="(criteria, cIdx) in scanner.criteriaList"
          :key="cIdx"
          class="gl-mt-2"
        >
          {{ criteria }}
        </li>
      </ul>
    </div>
  </div>

  <settings :settings="settings" />
  ...
</div>

5. Gate new rendering behind securityPoliciesKevFilter feature flag

The feature flag should control whether we interpret scanner objects. Two approaches (pick one):

Option A (preferred — logic in utils.js): In humanizeRule(), check window.gon.features.securityPoliciesKevFilter before entering the per-scanner code path. When the flag is off, treat object scanners the same as string scanners (extract .type only).

Option B (component-level): In details_drawer.vue, inject glFeatures and conditionally render scannerDetails only when the flag is on.

Recommendation: Use both — the utils function should gracefully handle both formats regardless, while the template conditionally shows per-scanner details behind the flag.

6. Add unit tests for utils.js

File: ee/spec/frontend/security_orchestration/components/policy_drawer/scan_result/utils_spec.js

Add tests for humanizeRules with:

  • Scanner objects with per-scanner vulnerabilities_allowed, severity_levels, vulnerability_attributes (including known_exploited, epss_score)
  • Mixed format scanners (some string, some object) — should gracefully handle
  • Empty scanner objects (no per-scanner overrides)
  • known_exploited and epss_score humanization in humanizeVulnerabilityAttributes

7. Add unit tests for details_drawer_spec.js

File: ee/spec/frontend/security_orchestration/components/policy_drawer/scan_result/details_drawer_spec.js

Add tests for:

  • Rendering per-scanner details when securityPoliciesKevFilter is enabled and scanners are objects
  • Not rendering per-scanner details when the feature flag is disabled
  • Not rendering per-scanner details when scanners are in the legacy string format
  • Rendering global criteria alongside per-scanner criteria

8. Update createHumanizedScanners tests

File: ee/spec/frontend/security_orchestration/components/policy_editor/utils_spec.js

Add test cases for createHumanizedScanners receiving scanner objects.

Verification steps

  1. Check out the corresponding branch
  2. Enable the security_policies_kev_filter feature flag
  3. Create/edit a scan result policy with the V2 rule builder (per-scanner settings enabled)
  4. Open the policy drawer for the saved policy
  5. Verify:
    • Global settings (severity, vulnerability states, age, attributes) are shown as a paragraph
    • Each scanner (e.g., Dependency Scanning, SAST) has its own paragraph with per-scanner criteria
    • known_exploited and epss_score attributes are rendered correctly
  6. Disable the feature flag and verify the drawer falls back to the legacy rendering (scanner names only, no per-scanner details)

MR Breakdown for Easier Review

!226175 (merged) spans 4 logical layers. Splitting into sequential MRs allows each to be reviewed in isolation. After all 4 are merged, !226175 (merged) is rebased — already-merged hunks drop cleanly.

Sequential order:

MR 1/4 — Add API Fuzzing & Coverage Fuzzing to scanner registry (independent)

Files: policy_editor/constants.js, policy_editor/utils.js, policy_editor/utils_spec.js, optimized_scan_selector_spec.js

  • Add REPORT_TYPE_API_FUZZING and REPORT_TYPE_COVERAGE_FUZZING to RULE_MODE_SCANNERS
  • Fix createHumanizedScanners to handle object-format scanners by extracting .type

Small, self-contained. Unblocks MR 3.


MR 2/4 — Simplify warn mode text in policy approvals (independent)

Files: policy_drawer/scan_result/policy_approvals.vue, policy_approvals_spec.js

  • Update warnModeText to a static string (removes dynamic approver/count interpolation)

Fully independent — can be merged in any order relative to MR 1.


MR 3/4 — Extend humanization for KEV and EPSS (depends on MR 1)

Files: policy_drawer/scan_result/utils.js, utils_spec.js

  • Add KNOWN_EXPLOITED mapping to humanizeVulnerabilityAttributes
  • Add humanizeEpssScore for operator + percentage rendering
  • Add buildScannerDetails → returns [{ name, criteriaList }] for object-format scanners, gated by securityPoliciesKevFilter FF

Pure logic layer — no template changes. Easiest to review in isolation.


MR 4/4 — Render per-scanner sections in policy drawer (depends on MR 3)

Files: policy_drawer/scan_result/details_drawer.vue, details_drawer_spec.js, locale/gitlab.pot

  • Destructure scannerDetails from humanizedRules
  • Render per-scanner name heading + criteria bullet list
  • Update locale strings

After MRs 1–4 merge: rebase !226175 (merged) — the four extracted hunks resolve automatically, leaving only any remaining incremental changes.

Edited by Artur Fedorov