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¶
- Inventory collection. A scheduled per-sensor
os_packagestask runs once a day and reports the installed software set. The defaultscheduledmode installs both the schedule and an ingest D&R rule that forwards only the tracked responses for analysis. - 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. - 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. - Resolutions. Every finding is implicitly open unless an operator records a resolution:
mitigated,accepted, orfalse_positive. Resolutions are keyed by a deterministic fingerprint so they survive rescans. - 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.
- 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-sensoros_packagesschedule (ext-vuln-mgmt-schedule) and the ingest rule that forwards tracked responses toprocess_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 everyos_packages_repevent regardless of whether the response carries the extension's tracking marker. Useful whenos_packagesis 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:*andlc: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 foropenssleverywhere 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 oldercurluntil 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 canonicallc: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 asmax_lc_riskonquery_cvesrows.
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 ofopen_countwhose 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
kevblock. - EPSS score: each CVE carries an
epssblock. - Exploit references:
query_cve(single-CVE detail) returns anexploit_refsarray (source∈exploit-db/metasploit/packetstorm/github-poc/vendor-or-other;tier∈weaponized/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:
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:
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 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
fingerprintOR(cve, normalized_package_name)must be supplied.sidis required whenscope=host. resolutionis one ofmitigated/accepted/false_positive, ornullto reopen the finding (deletes the row).expires_at(RFC3339) is only valid whenresolution=accepted. It is optional; an accepted resolution without anexpires_atnever lapses.case_numberis 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.
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:
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.
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):
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 byquery_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¶
- Subscribe to the extension on the marketplace page (default
scan_mode: scheduled). -
Tag your sensors. At minimum
lc:asset:criticality:*; ideally alsoexposure,env,owner, andcompliance. Mass-tag by selector: -
Wait for the first scheduled scan (within 24h) or trigger an out-of-band one for a representative host:
-
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¶
- Vulnerabilities page → CVEs tab → sort by LC Risk DESC (default).
- 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?
- From the CVE detail page click Run a hunt — the deeplink seeds an LCQL hunt with the CVE context for live investigation.
- Decide:
- If a compensating control is in place → Set resolution → mitigated.
resolved_atis 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.
- If a compensating control is in place → Set resolution → mitigated.
3. Track remediation progress¶
- 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. - The MTTR tile reads
resolved_at - first_seen_atper severity. It populates only after the first finding is set tomitigated. - 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:
- CVE row → Set resolution → accepted.
- Optionally set
expires_at(RFC3339, in the future). An accepted resolution without anexpires_atnever lapses. - The finding drops out of the open count until
expires_at. Whenexpires_atis 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. - To extend, call
set_finding_resolutionagain with a newexpires_at(the row is upserted in place;resolved_atandresolved_byare refreshed). - To formally close, transition to
mitigatedonce the patch lands. To reopen, passresolution: 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¶
- From the page header → Export ▾ → Compliance pack.
- 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.
- Resolution evidence is included per finding:
acceptedrows surface as "Risk accepted" (withexpires_atif set);mitigatedrows surface withresolved_at+resolved_by. - The
lc:asset:compliance:*tag drives the per-framework breakdown — a host taggedlc:asset:compliance:pcishows up under PCI; multi-tagged hosts appear in each.
6. Host-centric review¶
- Vulnerabilities page → Endpoints tab. Filter by
criticality:criticalorexposure:internet-facingto focus. - Click a row → side drawer shows asset metadata + per-host findings + top fix-version recommendation with a hunt deeplink.
- 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¶
- CVE detail page → 90-day EPSS sparkline (top right, next to the live percentile badge).
- 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.
-
For longer reports, raise
days(capped at 365):
8. Bulk operations¶
- CVEs tab → multi-select rows.
- Bulk action → Set resolution opens the resolution modal with the selected fingerprints as targets. Up to 100 per call.
- Each item is committed independently; the response carries per-item
appliedcount and per-item errors so a typo on row 7 doesn't roll back rows 1-6. - The frontend uses the response's
results[]directly — no follow-uplist_finding_resolutionsround 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.closedandmitigated-resolutionvuln_finding.state_changedevents — 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 toseverity=criticalonly.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_matchfor triage - Using Extensions — General extension subscription and management