Skip to content

Vulnerability Reporting

The Vulnerability Reporting extension (ext-vulnerability-reporting) collects per-endpoint software inventories, resolves them against the LimaCharlie CVE database, enriches each finding with CISA KEV and FIRST EPSS data, scores them with environment-aware risk, tracks per-finding resolutions across rescans, and surfaces the results in the LimaCharlie web app and via the extension API.

It is the first consumer of the canonical lc:asset:* tag namespace: asset criticality, exposure, environment, owner, and compliance tags are read directly off the sensors and used to prioritize findings and scope filters.

What it does

  1. Inventory collection. A scheduled per-sensor os_packages task runs once a day and reports the installed software set. The default scheduled mode installs both the schedule and an ingest D&R rule that forwards only the tracked responses for analysis.
  2. CVE resolution. Inventories are sent to cve.limacharlie.io, which maps each (package_name, package_version) pair to the set of CVEs that affect it.
  3. Enrichment. Each CVE is joined against CISA KEV and FIRST EPSS via cve.limacharlie.io/enrich. KEV / EPSS / criticality multiplier are folded into a 0-100 LC Risk score that is persisted on every finding row.
  4. Resolutions. Every finding is implicitly open unless an operator records a resolution: mitigated, accepted, or false_positive. Resolutions are keyed by a deterministic fingerprint so they survive rescans.
  5. Daily scans. A per-org daily tick runs three jobs: KEV-match emission, open-finding snapshot for the burndown tile, and EPSS-percentile snapshot for the per-CVE history sparkline.
  6. Surfacing. Findings are exposed via the LimaCharlie web app's Vulnerabilities page (KPI strip, trend tiles, filter chip-bar, KEV/EPSS columns, LC Risk score, lifecycle chips, CVE / asset detail pages, exec / compliance / remediation reports) and via the extension API (API Actions).

The extension is stateless aside from the per-org Spanner-backed tables (vuln_reports, vuln_finding_state, vuln_daily_snapshots, vuln_epss_history, plus rollup tables) and a small org_value keyed at ext_vuln_kev_known_set.

Setup

Navigate to the Vulnerability Reporting extension page in the marketplace. Select the organization and click Subscribe.

On subscription, the extension reconciles the D&R rules it owns to match the configured scan_mode:

  • scheduled (default): installs the daily per-sensor os_packages schedule (ext-vuln-mgmt-schedule) and the ingest rule that forwards tracked responses to process_packages (ext-vuln-mgmt-os-packages). Right default for almost every customer.
  • manual: installs no D&R rules. The operator drives tasking and forwarding themselves. Useful when scan cadence needs to be coordinated with another scheduler.
  • all: installs only the ingest rule, but it forwards every os_packages_rep event regardless of whether the response carries the extension's tracking marker. Useful when os_packages is already being collected on a separate cadence.

Reconciliation is idempotent: changing modes on an existing subscription removes any rule the new mode does not own and upserts the rules it does. Unsubscribing removes all extension-owned rules and drops every vuln_reports row for the org.

A dedicated webhook adapter is provisioned automatically so the emitted events have a destination on the event stream.

Permissions

The extension uses LimaCharlie's existing RBAC. Reading findings requires extension.use on ext-vulnerability-reporting. Subscribing and editing the configuration requires the standard extension management permissions.

Configuration

The configuration is edited on the extension page in the LimaCharlie web app. All fields are optional; defaults match the table below.

Field Type Default Description
scan_mode enum scheduled One of scheduled, manual, all. See Setup.
criticality_tag_overrides object {} Map of {your-tag → canonical-bucket} for organizations that already run their own asset-tag taxonomy. See Asset Metadata.

Example

{
  "scan_mode": "scheduled",
  "criticality_tag_overrides": {
    "crown-jewel": "critical",
    "tier-1": "high",
    "tier-3": "low"
  }
}

criticality_tag_overrides is consulted only when a sensor carries no canonical lc:asset:criticality:* tag. Explicit canonical tags always win, so an organization can migrate gradually. Override values must be canonical buckets; any other value is rejected at write time.

Asset metadata

The extension reads sensor tags in the lc:asset:* namespace and uses them to:

  • Prioritize findings. lc:asset:criticality:* is the multiplier in the LC Risk score.
  • Scope filters. lc:asset:env:* and lc:asset:exposure:* populate filter chips on the Vulnerabilities page.
  • Surface compliance views. lc:asset:compliance:* is multi-value; an asset can carry several regimes.
  • Route assignments. lc:asset:owner:* is exposed on the asset detail page so downstream workflows (Cases, Outputs to Slack/Jira/etc.) have the routing target available.

When include_tags=true is passed to query_endpoints or query_cve_vuln_hosts, each row carries an asset_metadata projection of the parsed tags:

{
  "sid": "550e8400-e29b-41d4-a716-446655440000",
  "hostname": "web-edge-001",
  "asset_metadata": {
    "criticality": "critical",
    "exposure": "internet-facing",
    "env": "prod",
    "owner": "platform-team",
    "compliance": ["pci"]
  }
}

Tags with malformed values for the closed-set fields (criticality, exposure, env) are dropped by the parser. See Asset Tag Namespace for the full schema.

Concepts

Lifecycle states

A finding has exactly one of two postures: open (the default — there is no resolution row) or resolved (a row exists in vuln_finding_state carrying one of three resolutions). Resolutions are keyed by a fingerprint, so they survive rescans.

Posture resolution Description
open — (no row) New finding. Implicit; nothing is persisted.
resolved mitigated Compensating control in place; finding is no longer counted as exploitable. Sets resolved_at (used by MTTR).
resolved accepted Risk has been formally accepted as an exception, optionally with an expires_at. Lapses back into the open count when expires_at is in the past.
resolved false_positive Confirmed not applicable (resolver mis-mapped the package, etc.).

The resolution row carries six columns: resolution, expires_at, case_number, resolved_at, resolved_by, updated_at. There is no per-finding audit log — re-running set_finding_resolution overwrites in place. To reopen a finding, call set_finding_resolution with resolution: null; this deletes the row.

case_number is reserved for an upcoming ext-cases linkage. It is plumbed end-to-end but not surfaced in the UI today; an operator opening a case from a finding will eventually have the case number persisted here so the resolution row points at the case carrying the richer metadata.

Lapsed acceptance

accepted is the only resolution that supports an expires_at. When expires_at is in the past at read time the UI derives a lapsed acceptance signal — the row is rendered with the same urgency as an open finding and surfaces in the lapsed-exception views. The Spanner row itself is not mutated; the lapsed signal is purely a resolution === 'accepted' && expires_at < now check at read time, so auditors can still see when and why the exception was originally granted.

Scope (org vs host)

Resolution rows are written at one of two scopes:

  • org — applies to every host carrying the same (cve, normalized_package_name). Use when the suppression is package-wide ("we accept this CVE for openssl everywhere until the next quarterly upgrade").
  • host — applies to one specific (cve, normalized_package_name, sid). Use when the rationale is host-specific ("this dev workstation is allowed to keep the older curl until reimage").

Read-overlay precedence: a host-scope row beats an org-scope one for the matching row. Both use the same fingerprint algorithm; the host fingerprint mixes in the sid.

Finding fingerprint

A fingerprint is a SHA-256 hex digest that gives a finding a stable identity across rescans, reboots, and partial reinstalls. The inputs are NUL-separated:

  • Org scope: SHA-256(cve + "\x00" + normalized_package_name)
  • Host scope: SHA-256(cve + "\x00" + sid + "\x00" + normalized_package_name)

normalized_package_name is the resolver's canonical product name — e.g. both "Google - Chrome" and "Google Chrome" normalize to chrome — so a per-org "accept this risk for chrome" mark applies to both. Clients never compute fingerprints; the backend derives them from the canonical inputs and echoes them on the response. Frontends that already have a fingerprint can pass it back in directly.

LC Risk

LC Risk is a 0-100 composite score the extension uses as the canonical prioritization key across the Vulnerabilities surface. It is computed at scan time during ApplySensorReports (when a sensor's resolved CVE set is being written to vuln_reports) and persisted on the row, so list-view sorts and filters do not have to recompute it on every read.

Inputs

  • The row's CVSS severity (critical / high / medium / low).
  • The CVE's EPSS percentile, fetched from /enrich.
  • KEV membership for the CVE, also from /enrich.
  • The host's lc:asset:criticality:* multiplier, parsed from the sensor's tags via the canonical lc:asset:* namespace.

Formula

base = SEVERITY_RANK[severity] / 4 * 60     // 0..60
base += epss_percentile * 25                // 0..25 (epss in [0,1])
base += 15 if in_kev else 0                 // flat bump
lc_risk = clamp(round(base * criticality_mult), 0, 100)

SEVERITY_RANK: critical=4, high=3, medium=2, low=1, unknown=0
CRITICALITY_MULT (backend): critical=1.6, high=1.3, medium=1.0, low=0.6, unknown=1.0

A row whose host has no canonical lc:asset:criticality:* tag (and whose org has no matching override) uses multiplier 1.0.

Frontend / backend low multiplier mismatch

The backend's low multiplier is 0.6; the frontend's hardcoded preview formula in src/utils/lcRisk.ts uses 0.8. The persisted lc_risk and max_lc_risk columns (what API responses and sorted UI columns read) are authoritative. The frontend constant only matters for client-side previews when the persisted value is missing.

Persistence

  • Per-host: vuln_reports.lc_risk (one int per (sensor, package, CVE) row).
  • Org rollup: vuln_cve_counts.max_lc_risk (max across all hosts for one CVE in the org). Returned as max_lc_risk on query_cves rows.

Buckets

The web app colour-buckets the score for badges:

Bucket LC Risk
critical ≥ 80
high 60-79
medium 40-59
low < 40

KEV (CISA Known Exploited Vulnerabilities)

The extension surfaces the CISA KEV catalogue per CVE. Fields exposed (from cve.limacharlie.io/enrich):

Field Type Description
in_kev bool True when the CVE is in the catalogue.
added string (YYYY-MM-DD) Date CISA added the CVE.
due string (YYYY-MM-DD) CISA's mandated remediation deadline (federal civilian agencies).
vendor string Vendor as listed by CISA.
product string Product as listed by CISA.
name string Vulnerability name as listed by CISA.
ransomware bool true iff CISA flagged "Known" ransomware-campaign use.

The KEV dataset is refreshed daily by the vulnerability-db ingest job. The data lives in Redis under the kev:* namespace and a refresh does not bust the resolver result cache — KEV/EPSS are fetched on the side via /enrich, not folded into /cves.

EPSS (FIRST Exploit Prediction Scoring System)

Every CVE is also scored by FIRST.org's EPSS model. Fields exposed:

Field Type Description
score float [0,1] Probability of in-the-wild exploitation in the next 30 days.
percentile float [0,1] Rank among all CVEs scored on this date.

EPSS is also refreshed daily.

EPSS history (90-day series)

EPSS percentile drifts as new exploit telemetry lands. The extension captures a daily snapshot for each CVE the org has at least one finding on, persisted to vuln_epss_history keyed by (oid, cve, snapshot_date). The capture happens during the daily Update tick (see Daily Update tick).

The frontend's CVE detail page renders a 90-day sparkline next to the live percentile, backed by query_epss_history. The default 90-day window matches the sparkline; pass a larger days (up to 365) to backfill a longer view for reports.

CVEs that the org has never had a finding on are not retained — there is no historical series to query for them. CVEs whose findings have all closed retain the snapshots already captured during their open period.

Daily snapshots

vuln_daily_snapshots is the per-day open-finding count by severity. The daily Update tick streams every vuln_reports row for the org, applies the resolution overlay (suppressing any row that has a resolution row that is not lapsed-accepted), and writes one row per (snapshot_date, severity) carrying:

  • open_count — total open findings in the bucket.
  • kev_count — subset of open_count whose CVE is in KEV at the time of capture.

A bucket with zero findings is still written on a quiet day so the burndown sparkline has continuity. Resolved findings are excluded by design — the snapshot represents "what the operator still owes," not "every CVE the resolver ever returned." Lapsed acceptances (where expires_at is in the past) flip back into the open count.

Daily Update tick

The platform scheduler (legion_extension_manager / legion_scheduler's ext-update-event cron) fires EventTypes.Update once per subscribed org per day, spread across 24h via MultiplexOID. The handler runs three scans sequentially with an independent 10-minute timeout per scan; one scan's failure does not suppress the others.

Order Scan Output
1 kev_match Emits vuln_finding.kev_match for CVEs that just entered KEV AND for which the org still has open findings. Diffed against an org_value "previously-known KEV set".
2 daily_snapshot Writes the per-severity open / KEV counts for today (see Daily snapshots).
3 epss_history Writes one EPSS row per distinct org CVE for today (see EPSS history).

The handler also re-reconciles D&R rules (idempotent) so a config change picks up on the next tick without requiring a manual re-subscribe.

The 24h spread means events and snapshots are not real-time. A KEV addition published by CISA at 09:00 UTC will surface to org A around 14:00 UTC and org B around 03:00 UTC the next day; this is by design — orgs are sharded across the day to keep the cron's load steady. Customers needing sub-day KEV matching should subscribe to the upstream CISA RSS feed directly.

KEV / EPSS enrichment at read time

The extension augments every CVE with KEV and EPSS data at read time via cve.limacharlie.io/enrich (POST {"cves": [...]}, returns {"results": {"<cve>": {kev?, epss?, exploit_refs?}, ...}}, capped at 200 CVEs per call).

Enrichment is opt-in per request via include_enrichment (defaults to true for user-facing actions). Set it to false for cheap admin queries that don't need the merged view.

When enrichment is included:

  • KEV match: each affected CVE in the response carries a kev block.
  • EPSS score: each CVE carries an epss block.
  • Exploit references: query_cve (single-CVE detail) returns an exploit_refs array (sourceexploit-db / metasploit / packetstorm / github-poc / vendor-or-other; tierweaponized / poc). The list-view actions deliberately omit exploit refs to keep page payloads small.

API actions

All actions are invoked via the standard extension request endpoint:

curl -s -X POST \
  "https://api.limacharlie.io/v1/extension/request/ext-vulnerability-reporting" \
  -H "Authorization: Bearer $LC_JWT" \
  -d oid="YOUR_OID" \
  -d action="<action>" \
  -d data='<json-body>'

The full request and response schemas live in the extension's requestSchema() declaration in refractionPOINT/ext-vulnerability-reporting. The web app's Vulnerabilities page is a reference consumer for every action listed below.

Read actions

Action Purpose
query_cves Paginated CVE rollup across the org. Sort by cve / count / severity / lc_risk. Returns max_lc_risk per row plus optional KEV/EPSS.
query_endpoints Paginated endpoint rollup with vulnerability counts. Returns asset_metadata when include_tags=true.
query_dashboard Index-based counts (severity, platform_string) powering the donut + bar charts.
query_host_vuln_packages All vulnerable packages and their CVEs for one sensor. Sort by cve / score / severity / lc_risk / package_name / package_name_package_version_cve. Returns lc_risk and fix_version per row.
query_cve_vuln_hosts All endpoints affected by one CVE.
query_cve_vuln_packages All (package_name, package_version) pairs in the org affected by one CVE, with the count of distinct sensors per pair.
query_cve Single-CVE detail blob. With include_enrichment=true returns the merged KEV / EPSS / exploit-refs view.
query_epss_history EPSS percentile + score time series for one CVE.
query_daily_snapshots Per-day open-finding counts (and KEV subset) for the burndown tile.
list_finding_resolutions Page through vuln_finding_state for the org with optional scope / resolution filters.

Write actions

Action Purpose
scan_packages Trigger an out-of-band os_packages scan against a specific sensor.
set_finding_resolution Set or clear a finding's resolution. Pass resolution: null to reopen (delete the row).
bulk_set_finding_resolution Apply a resolution change across up to 100 findings in one call.
reset_asset_findings Wipe every stored finding for one sensor (for reformat / reimage / decommission). Org-scope fingerprints that were only on this sensor fire vuln_finding.closed.

Internal action

Action Purpose
process_packages Internal callback fired by the ingest D&R rule. Not user-facing.

Action reference

query_cves

Request:

{
  "cursor": "",
  "limit": 100,
  "sort_by": "lc_risk",
  "sort_asc": false,
  "filters": { "severity": ["critical", "high"] },
  "search": { "field": "cve", "op": "contains", "value": "2024" },
  "include_tags": false,
  "include_enrichment": true,
  "filter_via_state": true
}

Response:

{
  "results": [
    {
      "cve": "CVE-2024-12345",
      "count": 42,
      "severity": "critical",
      "max_lc_risk": 92,
      "kev": { "in_kev": true, "added": "2024-08-12", "due": "2024-09-02", "vendor": "Acme", "product": "Foo", "name": "...", "ransomware": false },
      "epss": { "score": 0.92, "percentile": 0.99 },
      "resolution": null
    }
  ],
  "next_cursor": "100",
  "total_return_count": 1
}

Filters supported: severity, kev_only, epss_min, resolution, criticality, env, exposure. kev_only and epss_min force-enable include_enrichment. filter_via_state (default true) suppresses CVEs whose org-scope rollup is fully resolved.

query_endpoints

Request:

{
  "limit": 50,
  "sort_by": "hostname",
  "sort_asc": true,
  "filters": { "platform_string": ["linux"], "criticality": ["critical"] },
  "include_tags": true
}

Response:

{
  "endpoints": [
    {
      "sid": "550e8400-...",
      "hostname": "web-edge-001",
      "platform_string": "linux",
      "platform": 1,
      "count": 17,
      "severity": "critical",
      "asset_metadata": { "criticality": "critical", "exposure": "internet-facing", "env": "prod", "owner": "platform-team", "compliance": ["pci"] }
    }
  ],
  "cursor": "",
  "total_return_count": 1
}

query_host_vuln_packages

Request:

{
  "sid": "550e8400-...",
  "sort_by": "lc_risk",
  "sort_asc": false,
  "limit": 100,
  "filters": { "severity": ["critical", "high"] }
}

Response (one row per (package_name, package_version, cve)):

{
  "packages": [
    {
      "cve": "CVE-2024-12345",
      "package_name": "openssl",
      "package_name_package_version_cve": "openssl 3.0.2 CVE-2024-12345",
      "normalized_package_name": "openssl",
      "score": 9.8,
      "severity": "critical",
      "fix_version": "3.0.13",
      "lc_risk": 87,
      "kev": { "in_kev": true, "...": "..." },
      "epss": { "score": 0.81, "percentile": 0.98 },
      "resolution": null
    }
  ],
  "cursor": "",
  "total": 1
}

query_cve_vuln_hosts

Request:

{
  "cve": "CVE-2024-12345",
  "normalized_package_name": "openssl",
  "include_tags": true,
  "limit": 100
}

Pass normalized_package_name so the resolution overlay can compute host-scope fingerprints; without it, only org-scope resolution hits land. Filters supported: platform, platform_string, criticality, env, exposure, resolution.

query_cve

Request:

{ "cve_id": "CVE-2024-12345", "include_enrichment": true }

Response:

{
  "cve": { "id": "...", "descriptions": [...], "metrics": {...}, "references": [...], "configurations": [...] },
  "kev": { "in_kev": true, "..." : "..." },
  "epss": { "score": 0.92, "percentile": 0.99 },
  "exploit_refs": [
    { "source": "exploit-db", "url": "https://...", "tier": "weaponized" }
  ]
}

exploit_refs is the only place exploit references are returned. List-view actions deliberately omit them.

query_epss_history

Request:

{ "cve": "CVE-2024-12345", "days": 90 }

days defaults to 90, capped at 365. CVE must start with CVE-. Response is ordered snapshot_date ASC so it can be plotted directly:

{
  "history": [
    { "snapshot_date": "2026-02-08", "score": 0.0123, "percentile": 0.42 },
    { "snapshot_date": "2026-02-09", "score": 0.0145, "percentile": 0.45 }
  ]
}

CVEs with no historical coverage in the requested window return { "history": [] }.

query_daily_snapshots

Request:

{ "days": 30, "severities": ["critical"] }

days defaults to 30, capped at 365. severities defaults to all four canonical buckets. Response is ordered (snapshot_date ASC, severity ASC):

{
  "snapshots": [
    { "snapshot_date": "2026-04-30", "severity": "critical", "open_count": 42, "kev_count": 5 },
    { "snapshot_date": "2026-05-01", "severity": "critical", "open_count": 39, "kev_count": 5 }
  ]
}

set_finding_resolution

Request:

{
  "scope": "host",
  "cve": "CVE-2024-12345",
  "normalized_package_name": "openssl",
  "sid": "550e8400-...",
  "resolution": "accepted",
  "expires_at": "2026-06-15T00:00:00Z",
  "case_number": 12345
}

Field rules:

  • scope (org | host) is required.
  • Either fingerprint OR (cve, normalized_package_name) must be supplied. sid is required when scope=host.
  • resolution is one of mitigated / accepted / false_positive, or null to reopen the finding (deletes the row).
  • expires_at (RFC3339) is only valid when resolution=accepted. It is optional; an accepted resolution without an expires_at never lapses.
  • case_number is optional. It is reserved for upcoming ext-cases linkage; the field is plumbed through end-to-end but unused by the UI today.

Response:

{
  "data": {
    "fingerprint": "a3f2…",
    "scope": "host",
    "resolution": "accepted",
    "expires_at": "2026-06-15T00:00:00Z",
    "case_number": 12345,
    "resolved_at": "2026-05-10T18:30:00Z",
    "resolved_by": "alice@example.com",
    "updated_at": "2026-05-10T18:30:00Z"
  }
}

When resolution=null is passed the row is deleted and the response carries resolution: null with the now-cleared metadata fields. resolved_at is set to wall-clock time when the resolution becomes mitigated, so MTTR is derived from (resolved_at - first_seen_at) where first_seen_at comes from the matching vuln_reports row.

bulk_set_finding_resolution

Apply one resolution (with optional expires_at and case_number) to up to 100 targets in a single call. The resolution / expires_at / case_number are top-level fields; each target identifies a finding (scope + either fingerprint OR cve + normalized_package_name [+ sid for host scope]). Items are committed independently; partial successes are reported per-item.

{
  "resolution": "mitigated",
  "targets": [
    { "scope": "org", "cve": "CVE-2024-12345", "normalized_package_name": "openssl" },
    { "scope": "host", "cve": "CVE-2024-22222", "normalized_package_name": "openssl", "sid": "550e8400-..." }
  ]
}

To reopen a batch of findings, pass "resolution": null with the same targets shape.

Response:

{
  "data": {
    "applied": 2,
    "results": [
      { "index": 0, "row": { "fingerprint": "a3f2…", "scope": "org", "resolution": "mitigated", "resolved_at": "2026-05-10T18:30:00Z", "resolved_by": "alice@example.com", "updated_at": "2026-05-10T18:30:00Z" } },
      { "index": 1, "row": { "fingerprint": "b771…", "scope": "host", "resolution": "mitigated", "resolved_at": "2026-05-10T18:30:00Z", "resolved_by": "alice@example.com", "updated_at": "2026-05-10T18:30:00Z" } }
    ],
    "errors": []
  }
}

Each entry in results carries the original index plus the materialized row (same shape as the set_finding_resolution response). Per-target failures land in errors as { index, error } instead. Calls with more than 100 targets are rejected outright.

list_finding_resolutions

Page through resolution rows in the org. The list returns only resolved rows — open findings have no row to enumerate. All filters are optional and stack as AND.

{
  "scope": "host",
  "resolutions": ["accepted", "mitigated"],
  "limit": 100,
  "cursor": ""
}

Response:

{
  "data": {
    "resolutions": [
      {
        "fingerprint": "a3f2…",
        "scope": "host",
        "resolution": "accepted",
        "expires_at": "2026-06-15T00:00:00Z",
        "case_number": null,
        "resolved_at": "2026-05-10T18:30:00Z",
        "resolved_by": "alice@example.com",
        "updated_at": "2026-05-10T18:30:00Z"
      }
    ],
    "next_cursor": "..."
  }
}

limit defaults to 100; the response is ordered (scope ASC, fingerprint ASC). Lapsed acceptances are returned with resolution=accepted and an expires_at in the past — the lapsed signal is a derived UI check (resolution === 'accepted' && expires_at < now), not a separate enum value.

scan_packages

Trigger an out-of-band scan for one sensor:

{ "sid": "550e8400-..." }

Returns immediately; the scan completes asynchronously when the sensor reports back via the ingest D&R rule.

reset_asset_findings

Wipe every stored finding for one sensor. Use when the host has been reformatted, reimaged, or decommissioned and the existing findings no longer reflect reality. The next legitimate package scan repopulates findings from scratch.

{ "sid": "550e8400-..." }

Response carries the number of org-scope fingerprints that the reset cleared from the org entirely (one vuln_finding.closed event fires per cleared fingerprint):

{ "data": { "sid": "550e8400-...", "closed": 17 } }

Side effect: vuln_endpoint_scans.last_scan_at is stamped to the reset time, matching the semantic "operator declared this asset clean at this time". The next real package scan overwrites it.

Events emitted

The extension emits the following events through LimaCharlie's standard webhook adapter. Customers route them via Outputs to Jira, Slack, Cases, PagerDuty, etc.

Event When fired Notable fields
vuln_finding.created A new finding lands for an asset (rescan write path detected a new (oid, fingerprint) tuple). cve, severity, score, sid, hostname, kev, epss, first_seen
vuln_finding.closed The last sensor holding (cve, normalized_package_name) cleared it on a rescan, so the org-scope fingerprint is gone. Also fires per cleared fingerprint when reset_asset_findings wipes a host. cve, severity, score, sid, hostname, fingerprint
vuln_finding.kev_match A CVE just entered CISA KEV AND the org still has at least one open finding for it. cve, kev, epss
vuln_finding.state_changed set_finding_resolution / bulk_set_finding_resolution succeeded (including reopens, where the embedded resolution row carries scope + fingerprint + updated_at and the resolution-related fields are nil). fingerprint, embedded resolution row (scope, resolution, expires_at, case_number, resolved_at, resolved_by, updated_at)

Every event carries event_type, oid, and an optional fingerprint. Event delivery is best-effort: a failed webhook is logged at warn level and does not roll back the underlying state mutation.

Example output configuration

The same Outputs surface used for detections can route vulnerability events. The events ride the standard event stream, so use event_white_list to filter by event_type. To send vuln_finding.kev_match to Slack:

name: vuln-kev-to-slack
module: slack
type: event
slack_api_token: hive://secret/slack-webhook
slack_channel: "#vuln-priority"
event_white_list: |
  vuln_finding.kev_match

event_white_list is the standard newline-separated whitelist documented in Output stream structures; list additional event types on their own lines to fan out a single Output to multiple vuln_finding.* subtypes.

Web UI

The web app's Vulnerabilities section is the primary surface. It mirrors the API:

  • KPI strip — Total findings, KEV in environment, Open critical, Critical assets.
  • Trend tiles — MTTR by severity (driven by resolved_at), KEV coverage, Exposure score, and a 30-day open-critical burndown sparkline backed by query_daily_snapshots.
  • Filter chip-bar — multi-select chips for severity, criticality, exposure, environment, KEV-only, EPSS bucket (top-1 / top-5 / top-10 / any), and resolution (open / mitigated / accepted / false_positive / lapsed-accepted).
  • Charts — donut by severity + bar by platform; both reflect the active filter when one is set.
  • Endpoints / CVEs tabs — sortable columns including lc_risk, KEV badge, EPSS badge, resolution chip, asset-metadata cell. Bulk-select on the CVEs tab opens the resolution modal for batch resolution changes.
  • CVE detail page — affected hosts, affected packages, full CVE description, references, the merged KEV/EPSS view, exploit references, a 90-day EPSS sparkline backed by query_epss_history, the current resolution row (if any), and a one-click "Run a hunt" that opens an LCQL hunt deeplinked to the CVE.
  • Asset detail page — per-sensor view of vulnerable packages, asset metadata projection, top fix-version recommendation with hunt deeplink, and the resolutions currently in effect for the host.
  • Header exports — PDF (current view), CSV (current view), full-bundle ZIP, Executive PDF, Compliance pack PDF, Remediation plan CSV.

Workflows

Concrete operator playbooks. Each workflow is a numbered sequence; substitute <oid>, <sid>, etc. as appropriate. Examples assume the limacharlie CLI or a curl against the extension request endpoint.

1. First-time setup

  1. Subscribe to the extension on the marketplace page (default scan_mode: scheduled).
  2. Tag your sensors. At minimum lc:asset:criticality:*; ideally also exposure, env, owner, and compliance. Mass-tag by selector:

    limacharlie tag mass-add \
        --selector 'plat == "linux" and "edge" in tags' \
        --tag lc:asset:criticality:critical
    limacharlie tag mass-add \
        --selector 'plat == "linux" and "edge" in tags' \
        --tag lc:asset:exposure:internet-facing
    
  3. Wait for the first scheduled scan (within 24h) or trigger an out-of-band one for a representative host:

    curl -s -X POST "$LC_API/v1/extension/request/ext-vulnerability-reporting" \
      -H "Authorization: Bearer $LC_JWT" \
      -d oid="$OID" -d action="scan_packages" \
      -d data='{"sid":"<sid>"}'
    
  4. Open the Vulnerabilities page. The KPI strip and donut populate first; the trend tiles and burndown sparkline populate after the first daily Update tick (within 24h of subscribe).

2. Triage a critical finding

  1. Vulnerabilities page → CVEs tab → sort by LC Risk DESC (default).
  2. Click the row to open the side drawer. Review:
    • KEV block — is exploitation observed?
    • EPSS percentile — and the 90-day trend on the CVE detail page (query_epss_history) — is it climbing?
    • Affected hosts (query_cve_vuln_hosts) — what asset criticality / exposure mix?
  3. From the CVE detail page click Run a hunt — the deeplink seeds an LCQL hunt with the CVE context for live investigation.
  4. Decide:
    • If a compensating control is in place → Set resolution → mitigated. resolved_at is stamped and the finding drops out of the open count.
    • If the operator is going to actively patch → leave the finding as open (no resolution row); the burndown sparkline tracks remediation by attrition (the rescan removes the row when the patch lands).
    • If business has formally accepted the risk → Set resolution → accepted with an optional expires_at.

3. Track remediation progress

  1. The dashboard's burndown sparkline is the daily total of open critical findings (query_daily_snapshots, severities=["critical"], days=30). Slope is the operational signal.
  2. The MTTR tile reads resolved_at - first_seen_at per severity. It populates only after the first finding is set to mitigated.
  3. Rolling exports for steering committees: header → Executive PDF for an exec audience or Remediation plan (CSV) for ticket-import-ready output.

4. Risk acceptance

When a finding cannot be patched in time and the business formally accepts the risk:

  1. CVE row → Set resolution → accepted.
  2. Optionally set expires_at (RFC3339, in the future). An accepted resolution without an expires_at never lapses.
  3. The finding drops out of the open count until expires_at. When expires_at is in the past the UI derives a lapsed acceptance signal at read time — the row renders with the same urgency as an open finding so the operator knows to revisit, and the daily snapshot counts it back as open.
  4. To extend, call set_finding_resolution again with a new expires_at (the row is upserted in place; resolved_at and resolved_by are refreshed).
  5. To formally close, transition to mitigated once the patch lands. To reopen, pass resolution: null (deletes the row).

For richer per-finding context (rationale, evidence, multi-party sign-off, comments), open a case from the finding — the upcoming ext-cases linkage will record the case number on the resolution row.

curl -s -X POST "$LC_API/v1/extension/request/ext-vulnerability-reporting" \
  -H "Authorization: Bearer $LC_JWT" \
  -d oid="$OID" -d action="set_finding_resolution" \
  -d data='{
    "scope": "org",
    "cve": "CVE-2024-12345",
    "normalized_package_name": "openssl",
    "resolution": "accepted",
    "expires_at": "2026-09-01T00:00:00Z"
  }'

5. Compliance evidence export

  1. From the page header → Export ▾Compliance pack.
  2. The frontend pre-fetches per-host packages for every host with severity ≥ High (bounded 4-way concurrency), then renders a multi-page evidence pack mapped to SOC 2 / ISO 27001 control vocabulary.
  3. Resolution evidence is included per finding: accepted rows surface as "Risk accepted" (with expires_at if set); mitigated rows surface with resolved_at + resolved_by.
  4. The lc:asset:compliance:* tag drives the per-framework breakdown — a host tagged lc:asset:compliance:pci shows up under PCI; multi-tagged hosts appear in each.

6. Host-centric review

  1. Vulnerabilities page → Endpoints tab. Filter by criticality:critical or exposure:internet-facing to focus.
  2. Click a row → side drawer shows asset metadata + per-host findings + top fix-version recommendation with a hunt deeplink.
  3. From the asset detail page, bulk-select rows on the host vulnerabilities table → set resolution → mitigated (host scope) for findings the host has individually patched.

7. EPSS trend monitoring

  1. CVE detail page → 90-day EPSS sparkline (top right, next to the live percentile badge).
  2. A climbing slope is the early-warning signal that a CVE is becoming dangerous before it lands in KEV. Promote any CVE whose EPSS percentile is rising fast and that has no resolution row yet.
  3. For longer reports, raise days (capped at 365):

    { "cve": "CVE-2024-12345", "days": 365 }
    

8. Bulk operations

  1. CVEs tab → multi-select rows.
  2. Bulk action → Set resolution opens the resolution modal with the selected fingerprints as targets. Up to 100 per call.
  3. Each item is committed independently; the response carries per-item applied count and per-item errors so a typo on row 7 doesn't roll back rows 1-6.
  4. The frontend uses the response's results[] directly — no follow-up list_finding_resolutions round trip is needed.

Best practices

Choosing a resolution

Situation Resolution
Detected, no work yet started (leave implicit open — no row)
Patching is in flight (leave as open; the rescan removes the row when the patch lands)
Compensating control blocks exploitation; finding no longer counts as exploitable mitigated
Patch lands, rescan confirms gone (no action — the row drops out of vuln_reports on the next scan)
Cannot patch in the desired window, business-accepted exception accepted (optional expires_at)
Resolver false positive (wrong product / wrong version) false_positive

Do not use mitigated for "patch in progress" — resolved_at is stamped on entry, which would skew MTTR. Leave the finding open until the patch lands or a compensating control is documented.

Per-finding audit and collaboration

Per-finding audit detail is intentionally minimal — resolved_at, resolved_by, and updated_at are the only timestamps recorded, and updates overwrite in place. For richer collaboration (assignee, comments, evidence, classification, multi-party sign-off), use the upcoming ext-cases linkage: the case_number field on a finding's resolution row is reserved for this. When that integration ships, opening a case from a finding will populate case_number and the case will carry the audit history that the resolution row deliberately does not.

Tagging sensors meaningfully

Suggested tag combos for common environments:

  • DMZ web server: lc:asset:criticality:critical, lc:asset:exposure:internet-facing, lc:asset:env:prod, lc:asset:owner:platform-team, lc:asset:compliance:pci.
  • Internal worker: lc:asset:criticality:high, lc:asset:exposure:internal, lc:asset:env:prod, lc:asset:owner:platform-team.
  • Dev workstation: lc:asset:criticality:low, lc:asset:exposure:internal, lc:asset:env:dev, lc:asset:owner:it-help.
  • Build runner / CI: lc:asset:criticality:medium, lc:asset:exposure:internal, lc:asset:env:dev, lc:asset:owner:platform-team.

Drive these via limacharlie tag mass-add keyed off existing infrastructure tags or installation keys, so newly-enrolled sensors land already classified. See Asset Tag Namespace for a fuller pattern.

LC Risk vs raw CVSS

CVSS severity is environment-blind: a CVSS 9.8 critical scores the same on a dev laptop and on the customer-facing API gateway. LC Risk corrects for that by multiplying in the asset-criticality bucket — a low host caps roughly half the score, and a critical host inflates it by 60%. Always sort by LC Risk first; fall back to CVSS only when explaining the score externally.

Tracking remediation deadlines

The extension does not enforce a built-in remediation SLA — there is no SLA-window configuration, no per-criticality deadline persisted on findings, and no SLA-breach event. The signals that are available for cadence-tracking are:

  • vuln_finding.created — for stamping a target deadline at ingest in your ticketing system.
  • first_seen_at (returned on every finding row) — the basis any external SLA calculation should key off.
  • vuln_finding.closed and mitigated-resolution vuln_finding.state_changed events — for closing the loop and computing MTTR externally.

If you need deadline alerts, wire the per-criticality clock in your downstream system (Jira / Linear / PagerDuty) using first_seen_at + criticality from the event payload.

"Near-real-time" expectations

The daily Update tick is per-org, spread across 24h. KEV-match alerts and snapshot writes can lag by up to a full day for any one org. This is deliberate — the cron's load is steady rather than spiking. If you need sub-hour KEV detection, subscribe to the upstream CISA RSS feed in addition to this extension.

Integrating with downstream Outputs

Route the vuln_finding.* events to your existing alerting pipeline. A typical wiring:

  • vuln_finding.kev_match → Slack #vuln-priority + page on-call.
  • vuln_finding.created → ticketing system (Jira, Linear) so each new finding gets a tracked owner. High-volume on first scan; often filtered to severity=critical only.
  • vuln_finding.state_changed → SIEM. Each event carries the full resolution row, so a downstream consumer can rebuild the change feed without a follow-up read.
  • vuln_finding.closed → ticketing system (auto-resolve the matching ticket) and SIEM.

Glossary

Term Meaning
CVE Common Vulnerabilities and Exposures — public catalogue of disclosed vulnerabilities (CVE-YYYY-NNNN). Maintained by MITRE.
NVD National Vulnerability Database — NIST's CVE feed with CVSS scores, CPEs, references. The resolver's primary data source.
CVSS Common Vulnerability Scoring System — 0-10 numeric severity score plus low/medium/high/critical bucket.
CPE Common Platform Enumeration — structured product identifier used by NVD configurations to express which software a CVE applies to.
KEV CISA's Known Exploited Vulnerabilities catalogue. ~1500 entries; refreshed daily.
EPSS FIRST.org's Exploit Prediction Scoring System. Per-CVE probability + percentile of in-the-wild exploitation in the next 30 days.
LC Risk LimaCharlie's 0-100 environment-aware risk score. Persisted per-finding; see LC Risk.
MTTR Mean Time To Remediation. Computed from (resolved_at - first_seen_at) per severity bucket.
Criticality Asset importance bucket (critical/high/medium/low). Source: lc:asset:criticality:* or the configured override map.
Exposure Network reachability bucket (internet-facing/dmz/internal). Source: lc:asset:exposure:*.
Env Environment bucket (prod/staging/dev/test). Source: lc:asset:env:*.
Fingerprint SHA-256 hex of the canonical inputs that identify a finding across rescans. See Finding fingerprint.
Scope org (per-package, applies to every host) or host (per-package-per-sensor). Resolution precedence: host beats org.
Resolution Replaces the older state term. A finding is either implicitly open (no row) or resolved with resolution ∈ { mitigated, accepted, false_positive }. See Lifecycle states.
Lapsed acceptance An accepted resolution whose expires_at is in the past. Derived in the UI as resolution === 'accepted' && expires_at < now; the row is not mutated.
Daily Update tick Per-org per-day cron firing the three daily scans. Spread across 24h. See Daily Update tick.
KEV match The event fired when a CVE just entered KEV AND the org still has an open finding for it.
Case number Optional integer on a resolution row reserved for upcoming ext-cases linkage. Plumbed through the API today; not surfaced in the UI yet.

Reachability (deferred)

"Reachability" — in the sense Wiz, CrowdStrike, and similar tools use it: determining whether the vulnerable code path in a flagged package is actually loaded into a running process — is deferred. It requires sensor-side telemetry the EDR does not expose today (live module-load tracking, symbol-level call-graph coverage, etc.).

Until reachability is available, LC Risk's lc:asset:criticality:* multiplier is the closest in-product proxy for triage prioritization: it lets the score reflect "this CVE on a crown-jewel host" versus "this CVE on a development box" without requiring the underlying loaded-code-path signal.

See Also

  • lc:asset:* Tag Namespace — Asset metadata convention consumed by this extension
  • Sensor Tags — General tagging mechanism, API, and CLI
  • Outputs — Routing the vuln_finding.* events to external systems
  • Cases — Optional consumer of vuln_finding.kev_match for triage
  • Using Extensions — General extension subscription and management