From 431cda9cc6ae8c89e8e7c01f052a5df08adc2a7e Mon Sep 17 00:00:00 2001 From: Maxime Date: Sat, 9 May 2026 14:51:49 -0700 Subject: [PATCH 1/5] docs(vuln-uplift): asset-tag namespace and Vulnerability Reporting extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vulnerability-management uplift documentation: - New concept page for the canonical lc:asset:* tag namespace under 2-sensors-deployment/ — schema, validation rules, mass-tagging examples, and override-hatch behaviour. Intended to be a cross-cutting reference, not specific to vulnerabilities. - New extension overview for ext-vulnerability-reporting under 5-integrations/extensions/limacharlie/ — covers scan modes, configuration, asset metadata, KEV/EPSS enrichment, the Phase 2 lifecycle workflow and events (caveated as not-yet-GA), and the full API action surface. - Cross-links the two pages, adds them to mkdocs.yml nav and the LimaCharlie extensions index, and adds a 2026-05-09 entry to release notes summarizing the uplift. Refs refractionPOINT/tracking#4259 --- docs/10-release-notes/index.md | 12 + docs/2-sensors-deployment/asset-tags.md | 147 ++++++++++++ .../extensions/limacharlie/index.md | 1 + .../limacharlie/vulnerability-reporting.md | 209 ++++++++++++++++++ mkdocs.yml | 2 + 5 files changed, 371 insertions(+) create mode 100644 docs/2-sensors-deployment/asset-tags.md create mode 100644 docs/5-integrations/extensions/limacharlie/vulnerability-reporting.md diff --git a/docs/10-release-notes/index.md b/docs/10-release-notes/index.md index 7c6bfbb5..bb61bc66 100644 --- a/docs/10-release-notes/index.md +++ b/docs/10-release-notes/index.md @@ -4,6 +4,18 @@ Release notes for LimaCharlie platform components, organized by date. --- +## 2026-05-09 + +### Vulnerability Management Uplift + +Major uplift to the Vulnerability Reporting extension and its surfaces. + +- **Canonical asset-tag namespace**: introduces the `lc:asset:*` tag convention (criticality, exposure, environment, owner, compliance) for cross-cutting asset metadata. The Vulnerability Reporting extension is the first consumer; the namespace is intended to be reused across LimaCharlie surfaces. See [Asset Tag Namespace](../2-sensors-deployment/asset-tags.md). +- **Vulnerability Reporting extension**: new public-facing documentation covering setup, scan modes (`scheduled` / `manual` / `all`), per-criticality SLA windows, criticality-tag overrides, KEV + EPSS enrichment, and the full action surface. See [Vulnerability Reporting](../5-integrations/extensions/limacharlie/vulnerability-reporting.md). +- **Lifecycle workflow (Phase 2)**: documented finding states (`open`, `in_progress`, `mitigated`, `accepted`, `false_positive`), accepted-exception expiries with auto-revert, and the new `vuln_finding.*` events that customers can route via Outputs to Jira, Slack, Cases, etc. The lifecycle ships in Phase 2 of the uplift; the documentation lands ahead of GA. + +--- + ## 2026-02-08 ### Endpoint Agent 4.33.26 diff --git a/docs/2-sensors-deployment/asset-tags.md b/docs/2-sensors-deployment/asset-tags.md new file mode 100644 index 00000000..91aadc80 --- /dev/null +++ b/docs/2-sensors-deployment/asset-tags.md @@ -0,0 +1,147 @@ +# Asset Tag Namespace (`lc:asset:*`) + +`lc:asset:*` is a reserved sensor-tag namespace for marking endpoints with structured asset metadata — criticality, network exposure, environment, owner, and compliance regimes. It is a convention layered on top of [Sensor Tags](sensor-tags.md): there is no separate field on the sensor model, no migration to run, and no per-surface schema to extend. Any LimaCharlie surface that needs asset context (Vulnerabilities, D&R, Cases, Search, Query Console, Outputs, etc.) reads the same tags and gets a consistent view. + +The first consumer of the namespace is the [Vulnerability Reporting extension](../5-integrations/extensions/limacharlie/vulnerability-reporting.md), which uses `lc:asset:criticality:*` to drive risk scoring and SLA windows. Other surfaces will adopt the same parser as they need asset context. + +## Why tags + +LimaCharlie tags are already the cross-cutting metadata mechanism for sensors: + +- They are visible on every event under `routing.tags`. +- They are queryable in D&R rules, sensor selectors, LCQL, and the API. +- They can be applied at enrollment time, by mass-tagging selectors, by D&R response actions, or manually through the web app and CLI. + +Adding a new sensor-model field for asset metadata would require schema changes, per-surface adoption, and a separate write path. A tag convention sidesteps all of that: every surface that already understands tags inherits the new metadata for free. + +## Schema + +The namespace defines five tag prefixes. The value follows the prefix as a third colon-separated segment. + +| Tag | Values | Cardinality | Purpose | +|---|---|---|---| +| `lc:asset:criticality:` | `critical`, `high`, `medium`, `low` | Singleton | Asset importance. Used as a risk-score multiplier and to drive priority sort and SLA windows. | +| `lc:asset:exposure:` | `internet-facing`, `dmz`, `internal` | Singleton | Network reachability. Feeds risk scoring and filter chips. | +| `lc:asset:env:` | `prod`, `staging`, `dev`, `test` | Singleton | Environment for filtering and suppression scoping. | +| `lc:asset:owner:` | Free text | Singleton | Routing target for assignment / paging (e.g. a team name, an email, a Slack handle). | +| `lc:asset:compliance:` | Free text (e.g. `pci`, `hipaa`, `sox`, `gdpr`) | Multi-value | Compliance regimes the asset is subject to. A sensor can carry several at once. | + +### Validation rules + +The closed-set fields (`criticality`, `exposure`, `env`) only accept the values listed above. Tags with malformed or unknown values for those fields are dropped by the parser — this prevents typos like `lc:asset:criticality:hi` from creating a phantom bucket in dashboards or SLAs. + +`owner` and `compliance` accept any non-empty value after the prefix. + +If a sensor carries multiple tags for the same singleton field (for example, both `lc:asset:env:prod` and `lc:asset:env:staging`), the parser picks the first match in lexical order. This is deterministic but should be avoided — fix the tags rather than rely on the resolution order. + +`compliance` values are deduplicated and sorted alphabetically when emitted as JSON, so `lc:asset:compliance:pci` plus `lc:asset:compliance:hipaa` always renders as `["hipaa","pci"]` regardless of tag order. + +## Applying tags + +Use the [`limacharlie` CLI](../6-developer-guide/cli.md) (or the API equivalents documented in [Sensor Tags](sensor-tags.md)). + +### Tag a single sensor + +```bash +limacharlie tag add --sid SENSOR_ID --tag lc:asset:criticality:critical +limacharlie tag add --sid SENSOR_ID --tag lc:asset:exposure:internet-facing +limacharlie tag add --sid SENSOR_ID --tag lc:asset:env:prod +limacharlie tag add --sid SENSOR_ID --tag lc:asset:owner:platform-team +limacharlie tag add --sid SENSOR_ID --tag lc:asset:compliance:pci +``` + +### Tag a fleet by selector + +Mass-tagging is the practical path for any non-trivial environment. The selector uses [sensor selector expressions](../8-reference/sensor-selector-expressions.md). + +```bash +# All Linux production hosts: env=prod +limacharlie tag mass-add \ + --selector 'plat == "linux" and "prod" in tags' \ + --tag lc:asset:env:prod + +# Engineering bench (already tagged 'bender') becomes dev +limacharlie tag mass-add \ + --selector '"bender" in tags' \ + --tag lc:asset:env:dev + +# Internet-facing tier picked up via existing 'edge' tag +limacharlie tag mass-add \ + --selector '"edge" in tags' \ + --tag lc:asset:exposure:internet-facing + +# All assets in the cardholder-data scope +limacharlie tag mass-add \ + --selector '"cde" in tags' \ + --tag lc:asset:compliance:pci +``` + +Tags applied via mass-add are persistent (no TTL) unless `--ttl` is passed. Re-running mass-add is idempotent. + +### Apply at enrollment time + +Installation keys can carry a fixed list of tags applied to every sensor that enrols against them. Bake the asset metadata into separate keys per asset class — for example, one key per environment + criticality combination — so the metadata lands the moment the sensor connects. + +### Apply via D&R rules + +D&R rules can add or remove tags as a response action. This is useful when asset state can be inferred from telemetry — for example, tagging a host as `lc:asset:exposure:internet-facing` when it starts answering on a public IP, or as `lc:asset:env:prod` based on a hostname pattern. + +```yaml +respond: + - action: add tag + tag: lc:asset:env:prod +``` + +## How surfaces consume the tags + +Each consumer surface uses a canonical parser that turns a sensor's tag set into a structured `AssetMetadata` object: + +- **Go:** `ParseAssetMetadata(tags)` returns an `AssetMetadata` struct with `Criticality`, `Exposure`, `Env`, `Owner`, and `Compliance` fields. Used by extensions and backend services. +- **TypeScript:** `parseAssetMetadataFromTags(tags)` mirrors the Go shape for use in the LimaCharlie web app and any TypeScript SDK consumer. + +Both implementations share the same prefix list, the same closed-set validation, and the same tie-breaking rules so a tag set is interpreted identically across the platform. + +The Vulnerability Reporting extension exposes the parsed metadata under an `asset_metadata` field on every endpoint and finding when `include_tags=true` is requested. See the [extension page](../5-integrations/extensions/limacharlie/vulnerability-reporting.md) for the response shape. + +## Override hatches + +Organizations that already run an asset taxonomy (for example, a long-standing `crown-jewel` / `tier-1` / `tier-3` scheme) can map their existing tags into the canonical buckets without re-tagging the fleet. + +Today the override is exposed by the Vulnerability Reporting extension as a `criticality_tag_overrides` configuration field: + +```json +{ + "criticality_tag_overrides": { + "crown-jewel": "critical", + "tier-1": "high", + "tier-3": "low" + } +} +``` + +The mapping is consulted only when no canonical `lc:asset:criticality:*` tag is present on the sensor. Explicit canonical tags always win, so an org can migrate gradually: leave the override map in place, start applying canonical tags to the most important assets, and remove the override entries as coverage grows. + +Override values must be one of the four canonical buckets; any other value is silently ignored at read time and rejected when the configuration is written. + +Other surfaces will adopt the same override pattern (or its equivalent) as they consume the namespace. + +## Sample real-world tagging + +A hypothetical SaaS company runs four classes of assets. The tag plan: + +| Asset class | Tags | +|---|---| +| Production app servers (public-facing, in PCI scope) | `lc:asset:criticality:critical`, `lc:asset:exposure:internet-facing`, `lc:asset:env:prod`, `lc:asset:compliance:pci`, `lc:asset:owner:platform-team` | +| Production database tier (internal, in PCI scope) | `lc:asset:criticality:critical`, `lc:asset:exposure:internal`, `lc:asset:env:prod`, `lc:asset:compliance:pci`, `lc:asset:owner:platform-team` | +| Staging cluster (DMZ, no compliance scope) | `lc:asset:criticality:medium`, `lc:asset:exposure:dmz`, `lc:asset:env:staging`, `lc:asset:owner:platform-team` | +| Engineering laptops (internal, dev work) | `lc:asset:criticality:low`, `lc:asset:exposure:internal`, `lc:asset:env:dev`, `lc:asset:owner:it-help` | +| HR file share (internal, in HIPAA + SOX scope) | `lc:asset:criticality:high`, `lc:asset:exposure:internal`, `lc:asset:env:prod`, `lc:asset:compliance:hipaa`, `lc:asset:compliance:sox`, `lc:asset:owner:hr-ops` | + +Driven by `limacharlie tag mass-add` calls keyed off existing infrastructure tags (e.g. an installation key per asset class, a hostname prefix, a cloud-provider label propagated via the cloud adapters), the entire fleet can be classified in a single pass and kept current as new sensors enrol. + +## See Also + +- [Sensor Tags](sensor-tags.md) — General tagging mechanism and API surface +- [Sensor Selector Expressions](../8-reference/sensor-selector-expressions.md) — Selector syntax used by mass-add +- [Vulnerability Reporting Extension](../5-integrations/extensions/limacharlie/vulnerability-reporting.md) — First consumer of the namespace +- [`limacharlie` CLI](../6-developer-guide/cli.md) — `tag add` / `tag mass-add` reference diff --git a/docs/5-integrations/extensions/limacharlie/index.md b/docs/5-integrations/extensions/limacharlie/index.md index 13c46a76..e680fef3 100644 --- a/docs/5-integrations/extensions/limacharlie/index.md +++ b/docs/5-integrations/extensions/limacharlie/index.md @@ -19,6 +19,7 @@ Extensions built and maintained by LimaCharlie that extend the platform with add - [Reliable Tasking](reliable-tasking.md) - Guaranteed task delivery to sensors - [Sensor Cull](sensor-cull.md) - Automatic cleanup of inactive sensors - [Usage Alerts](usage-alerts.md) - Usage threshold notifications +- [Vulnerability Reporting](vulnerability-reporting.md) - Per-endpoint software inventory, CVE resolution, KEV/EPSS enrichment - [YARA Manager](yara-manager.md) - YARA rule management ## See Also diff --git a/docs/5-integrations/extensions/limacharlie/vulnerability-reporting.md b/docs/5-integrations/extensions/limacharlie/vulnerability-reporting.md new file mode 100644 index 00000000..0f2adf6c --- /dev/null +++ b/docs/5-integrations/extensions/limacharlie/vulnerability-reporting.md @@ -0,0 +1,209 @@ +# 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, 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](../../../2-sensors-deployment/asset-tags.md): asset criticality, exposure, environment, owner, and compliance tags are read directly off the sensors and used to prioritize findings, scope filters, and parameterize remediation SLAs. + +## 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. The resolver caches aggressively to keep per-call cost low. +3. **Enrichment.** The extension joins each CVE against CISA KEV (Known Exploited Vulnerabilities) and FIRST EPSS (Exploit Prediction Scoring System) at read time. Enrichment data refreshes daily. +4. **Surfacing.** Findings are exposed via: + - The LimaCharlie web app's Vulnerabilities page (filter chip-bar, KPI strip, KEV/EPSS columns, LC Risk score, lifecycle state chips, routable CVE / asset detail pages, and exec / compliance / remediation reports). + - The extension request API, listed under [API Actions](#api-actions). + +The extension is stateless aside from the per-org Spanner-backed `vuln_reports` store that holds the most recent inventory and resolved CVE set per sensor. + +## Setup + +Navigate to the [Vulnerability Reporting extension page](https://app.limacharlie.io/add-ons/extension-detail/ext-vulnerability-reporting) 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`). This is the 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 and you simply want every report processed. + +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. + +!!! info "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](#setup). | +| `sla_windows_hours` | object | `{"critical":168,"high":720,"medium":2160,"low":4320}` | Per-criticality remediation deadlines in hours. Keys must be one of `critical` / `high` / `medium` / `low`; values must be positive integers. Unset keys fall back to the defaults (1 week / 30 days / 90 days / 180 days, matching common NIST and FedRAMP baselines). | +| `criticality_tag_overrides` | object | `{}` | Map of `{your-tag → canonical-bucket}` for organizations that already run their own asset-tag taxonomy. See [Asset Metadata](#asset-metadata). | + +### Example + +```json +{ + "scan_mode": "scheduled", + "sla_windows_hours": { + "critical": 24, + "high": 168, + "medium": 720, + "low": 2160 + }, + "criticality_tag_overrides": { + "crown-jewel": "critical", + "tier-1": "high", + "tier-3": "low" + } +} +``` + +The `criticality_tag_overrides` map is consulted only when a sensor carries no canonical `lc:asset:criticality:*` tag. Explicit canonical tags always win, so an organization can migrate gradually: leave the override map in place, start applying canonical tags to the most important assets first, and remove the override entries as coverage grows. 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](../../../2-sensors-deployment/asset-tags.md) and uses them to: + +- **Prioritize findings.** `lc:asset:criticality:*` is the multiplier in the LC Risk score and the source for the per-criticality SLA window. +- **Scope filters.** `lc:asset:env:*` and `lc:asset:exposure:*` populate filter chips on the Vulnerabilities page and feed report filters. +- **Surface compliance views.** `lc:asset:compliance:*` is multi-value; an asset can carry several regimes and shows up in each. +- **Route assignments.** `lc:asset:owner:*` is exposed on the asset detail page so workflows downstream of the extension (Cases, Outputs to Slack/Jira/etc.) have the routing target available. + +When `include_tags=true` is passed to a query that returns endpoints (`query_endpoints`, `query_cve_vuln_hosts`, `query_host_vuln_packages`), each row carries an `asset_metadata` projection of the parsed tags: + +```json +{ + "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 and do not appear in `asset_metadata`. See [Asset Tag Namespace](../../../2-sensors-deployment/asset-tags.md) for the full schema and validation rules. + +## KEV and EPSS enrichment + +The extension augments every CVE with two external feeds at read time: + +- **[CISA KEV](https://www.cisa.gov/known-exploited-vulnerabilities-catalog)** — the U.S. Cybersecurity and Infrastructure Security Agency's catalog of vulnerabilities with confirmed exploitation in the wild. Membership is a strong signal to prioritize remediation. +- **[FIRST EPSS](https://www.first.org/epss/)** — the Exploit Prediction Scoring System, a probabilistic score (0.0–1.0) and percentile estimating the likelihood that a CVE will be exploited in the next 30 days. + +Both feeds are refreshed daily. Resolution and enrichment data are served by `cve.limacharlie.io`; the extension's `/enrich` integration merges them into the response. + +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 with the date the CVE was added, the required action, and the due date if specified. +- EPSS score: each CVE carries an `epss` block with the probability and the percentile. +- Exploit references: `query_cve` (single-CVE detail) returns an `exploit_refs` array. The list-view actions deliberately omit exploit refs to keep page payloads small. + +## Lifecycle workflow + +!!! warning "Phase 2" + The lifecycle workflow described in this section ships in Phase 2 of the vulnerability-management uplift. State, exception, and event handling may not be enabled in production at the time you read this; the API actions and event names listed below are the contract Phase 2 will land against. Phase 1 (the inventory, resolution, enrichment, and asset metadata pipeline above) is live independently. + +Each finding has a state field that tracks where it is in the remediation pipeline: + +| State | Description | +|-------|-------------| +| `open` | New finding. Counts against SLA. | +| `in_progress` | A remediation effort is underway. | +| `mitigated` | Compensating control in place; the finding is no longer counted as exploitable. | +| `accepted` | Risk has been formally accepted as an exception. Requires a reason and an expiry. | +| `false_positive` | Confirmed not applicable to the asset (e.g. version detection misfire). | + +### Accepted exceptions + +`accepted` is the only state that carries a required expiry. An accepted finding stops counting against the SLA until the expiry passes. Accepted findings whose expiry has lapsed automatically revert to `open` at read time, and the finding is flagged as `expired_exception=true` in the response so audit reports can show that an exception ran out without being renewed. + +State changes are recorded in an audit trail with the actor, timestamp, optional reason, and optional ticket reference. + +### Events emitted + +The extension emits the following events through LimaCharlie's standard event surface. Customers route them via Outputs to Jira, Slack, ext-cases, PagerDuty, etc. + +| Event | When fired | +|-------|-----------| +| `vuln_finding.created` | A new finding lands for an asset. | +| `vuln_finding.kev_match` | A finding is enriched with a CISA KEV match. Fired in addition to `vuln_finding.created` so high-priority alerting can subscribe to it directly. | +| `vuln_finding.state_changed` | The finding's state moves between any two values. Includes the prior state, new state, actor, and reason. | +| `vuln_finding.sla_breach_warning` | The finding is approaching its SLA window. Fires once at 80% of the window elapsed and again when the window is breached. | + +#### Example output configuration + +The same Outputs surface used for detections can route vulnerability events. To send `vuln_finding.kev_match` to Slack: + +```yaml +name: vuln-kev-to-slack +module: slack +type: event +filter_event_type: vuln_finding.kev_match +slack_api_token: hive://secret/slack-webhook +slack_channel: "#vuln-priority" +``` + +## API actions + +All actions are invoked via the standard extension request endpoint: + +```bash +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="" \ + -d data='' +``` + +### Phase 1 (live) + +| Action | Purpose | +|--------|---------| +| `query_cves` | Paginated CVE rollup across the org. Sort by CVE, count, or severity. KEV/EPSS optional. | +| `query_endpoints` | Paginated endpoint rollup with vulnerability counts. Returns `asset_metadata` when `include_tags=true`. | +| `query_dashboard` | Index-based counts powering the Vulnerabilities dashboard. | +| `query_host_vuln_packages` | All vulnerable packages and their CVEs for one sensor. | +| `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 that carry each pair. | +| `query_cve` | Single-CVE detail blob. With `include_enrichment=true` returns the merged KEV / EPSS / exploit-refs view. | +| `scan_packages` | Trigger an out-of-band `os_packages` scan against a specific sensor. | + +### Phase 2 (lifecycle — see caveat above) + +| Action | Purpose | +|--------|---------| +| `set_finding_state` | Move one finding to a new state. Accepts `reason`, `ticket`, and (for `accepted`) `expires_at`. | +| `clear_finding_state` | Reset a finding to `open`, discarding the current state. | +| `bulk_set_finding_state` | Apply a state change across many findings in one call. | +| `list_finding_states` | Return the audit trail of state changes for a finding. | + +The full request and response schemas live in the extension's `requestSchema()` declaration in [`refractionPOINT/ext-vulnerability-reporting`](https://github.com/refractionPOINT/ext-vulnerability-reporting). The web app's Vulnerabilities page is a reference consumer for every action listed above. + +## Web UI + +The web app's Vulnerabilities section is the primary surface. It mirrors the API: + +- **Filter chip-bar** — multi-select chips for severity, criticality, exposure, environment, compliance regime, KEV match, EPSS percentile, and lifecycle state. +- **KPI strip** — top-line counts of total findings, KEV-matched findings, SLA-breached findings, and accepted exceptions nearing expiry. +- **Endpoints / CVEs / Packages** tabs — each with sortable columns, KEV and EPSS badges, and the LC Risk score (a composite of severity, criticality, exposure, KEV membership, and EPSS percentile). +- **Asset detail page** — per-sensor view of vulnerable packages, asset metadata projection, and recent state changes. +- **CVE detail page** — affected hosts, affected packages, full CVE description, references, and the merged KEV/EPSS view. +- **Reports** — exec summary, compliance scope (driven by `lc:asset:compliance:*`), and a remediation report keyed off SLA windows. + +Screenshots are deferred for this revision while the UI continues to land in production. They will be added in a follow-up once the surfaces stabilize. + +## See Also + +- [`lc:asset:*` Tag Namespace](../../../2-sensors-deployment/asset-tags.md) — Asset metadata convention consumed by this extension +- [Sensor Tags](../../../2-sensors-deployment/sensor-tags.md) — General tagging mechanism, API, and CLI +- [Outputs](../../outputs/index.md) — Routing the Phase 2 events to external systems +- [Cases](cases.md) — Optional consumer of `vuln_finding.kev_match` for triage +- [Using Extensions](../using-extensions.md) — General extension subscription and management diff --git a/mkdocs.yml b/mkdocs.yml index 8ba880e2..dd3951a6 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -216,6 +216,7 @@ nav: - Installation Keys: 2-sensors-deployment/installation-keys.md - Sensor Connectivity: 2-sensors-deployment/connectivity.md - Sensor Tags: 2-sensors-deployment/sensor-tags.md + - Asset Tags (lc:asset:*): 2-sensors-deployment/asset-tags.md - Log Collection Guide: 2-sensors-deployment/log-collection-guide.md - Telemetry Index: 2-sensors-deployment/telemetry-index.md - Endpoint Agents: @@ -409,6 +410,7 @@ nav: - Reliable Tasking: 5-integrations/extensions/limacharlie/reliable-tasking.md - Sensor Cull: 5-integrations/extensions/limacharlie/sensor-cull.md - Usage Alerts: 5-integrations/extensions/limacharlie/usage-alerts.md + - Vulnerability Reporting: 5-integrations/extensions/limacharlie/vulnerability-reporting.md - YARA Manager: 5-integrations/extensions/limacharlie/yara-manager.md - Third Party: - Overview: 5-integrations/extensions/third-party/index.md From c2ff010537d131625128e4730138c55352c326bd Mon Sep 17 00:00:00 2001 From: Maxime Date: Sat, 9 May 2026 17:54:45 -0700 Subject: [PATCH 2/5] docs(vuln-uplift): document LC Risk persistence, audit log, EPSS history, and reachability deferral Extends the Vulnerability Reporting extension page with the new pieces landing in this round of the vuln-management uplift: - New API actions: list_finding_state_history (audit log read path) and query_epss_history (90-day EPSS percentile time-series, backs the CVE-detail sparkline). Replaces the prior round's placeholder list_finding_states entry with the shipping name. - Row schema additions: query_host_vuln_packages now returns fix_version (NVD-derived) and lc_risk; query_cves now returns max_lc_risk; set_finding_state and bulk_set_finding_state responses now echo first_seen_at and remediated_at. - New sort_by value lc_risk on query_cves (max_lc_risk DESC) and query_host_vuln_packages (lc_risk DESC). - Lifecycle audit log subsection: vuln_finding_state_history table, state="cleared" event for clear_finding_state, list_finding_state_history as the read path, with cross-link from Accepted exceptions. - mitigated_by validation: write-time D&R hive lookup, hard-fail with HTTP 400 on unknown rule key, no soft-fail mode. - LC Risk section: scan-time computation in ApplySensorReports, persisted to vuln_reports.lc_risk and rolled up to vuln_cve_counts.max_lc_risk, formula reproduced verbatim and noted as hardcoded-in-sync with src/utils/lcRisk.ts in web-app-frontend. - EPSS history section: daily Update-tick capture model, 90-day default / 365-day max window, reference consumer is the CVE-detail sparkline. - Reachability subsection: documented as deferred (sensor-side telemetry not exposed today); LC Risk criticality multiplier is the interim proxy for triage prioritization. Cross-links from the audit-log and EPSS-history sections to the actions they describe; cross-links from the action table back to the conceptual sections. mkdocs build --strict and markdownlint-cli2 both clean. Refs: refractionPOINT/tracking#4259 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../limacharlie/vulnerability-reporting.md | 175 +++++++++++++++++- 1 file changed, 168 insertions(+), 7 deletions(-) diff --git a/docs/5-integrations/extensions/limacharlie/vulnerability-reporting.md b/docs/5-integrations/extensions/limacharlie/vulnerability-reporting.md index 0f2adf6c..6ab0bbac 100644 --- a/docs/5-integrations/extensions/limacharlie/vulnerability-reporting.md +++ b/docs/5-integrations/extensions/limacharlie/vulnerability-reporting.md @@ -124,7 +124,17 @@ Each finding has a state field that tracks where it is in the remediation pipeli `accepted` is the only state that carries a required expiry. An accepted finding stops counting against the SLA until the expiry passes. Accepted findings whose expiry has lapsed automatically revert to `open` at read time, and the finding is flagged as `expired_exception=true` in the response so audit reports can show that an exception ran out without being renewed. -State changes are recorded in an audit trail with the actor, timestamp, optional reason, and optional ticket reference. +State changes are recorded in an audit trail; see [Audit log](#audit-log) below. + +### `mitigated_by` validation + +When `set_finding_state` is called with `state="mitigated"`, the optional `mitigated_by` field accepts the key of a D&R rule that provides the compensating control. The extension validates the supplied key against the org's D&R hive at write time. Unknown rule keys are rejected with HTTP 400. There is no soft-fail mode that records a warning and accepts the write — the constraint is hard, so a stale or typo'd rule reference cannot land in the audit trail. + +### Audit log + +Every state mutation appends a row to the `vuln_finding_state_history` table. The row records `event_at`, the resulting `state`, and the optional `reason`, `expires_at`, `assignee`, `mitigated_by`, and `updated_by` values that were supplied with the mutation. The `clear_finding_state` action also appends a row, with `state="cleared"`, so a reset is itself a first-class event in the trail and is not silently elided. + +The read path is the [`list_finding_state_history`](#api-actions) action. Compliance audits looking for the trail of acceptances and rationales — "who accepted CVE-2024-XXXXX on March 12, with what reason, and until when?" — query that action per finding. The history is returned ordered `event_at DESC` so the most recent mutation is first. ### Events emitted @@ -174,32 +184,183 @@ curl -s -X POST \ | `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 that carry each pair. | | `query_cve` | Single-CVE detail blob. With `include_enrichment=true` returns the merged KEV / EPSS / exploit-refs view. | +| `query_epss_history` | EPSS percentile time-series for a CVE. See [EPSS history](#epss-history). | | `scan_packages` | Trigger an out-of-band `os_packages` scan against a specific sensor. | ### Phase 2 (lifecycle — see caveat above) | Action | Purpose | |--------|---------| -| `set_finding_state` | Move one finding to a new state. Accepts `reason`, `ticket`, and (for `accepted`) `expires_at`. | -| `clear_finding_state` | Reset a finding to `open`, discarding the current state. | -| `bulk_set_finding_state` | Apply a state change across many findings in one call. | -| `list_finding_states` | Return the audit trail of state changes for a finding. | +| `set_finding_state` | Move one finding to a new state. Accepts `reason`, `ticket`, `assignee`, `mitigated_by` (validated — see [`mitigated_by` validation](#mitigated_by-validation)), and (for `accepted`) `expires_at`. The response echoes `first_seen_at` and `remediated_at` so callers do not need a follow-up read. | +| `clear_finding_state` | Reset a finding to `open`, discarding the current state. The reset is recorded in the audit log as a `state="cleared"` event. | +| `bulk_set_finding_state` | Apply a state change across many findings in one call. Each per-item response also echoes `first_seen_at` and `remediated_at`. | +| `list_finding_state_history` | Read the audit log of state mutations for one finding. See [Audit log](#audit-log). | + +#### Action request shapes + +##### `list_finding_state_history` + +Request — finding-by-key form: + +```json +{ + "scope": "host", + "cve": "CVE-2024-12345", + "normalized_package_name": "openssl", + "sid": "550e8400-e29b-41d4-a716-446655440000", + "limit": 100 +} +``` + +Request — fingerprint form (use the fingerprint returned by the row-level read actions): + +```json +{ + "fingerprint": "a3f2…", + "limit": 100 +} +``` + +`scope` is `host` for per-host findings (and requires `sid`) and `org` for org-wide findings (no `sid`). `limit` defaults to the extension's standard page size and is capped at the standard maximum. + +Response: + +```json +{ + "history": [ + { + "event_at": 1715212345000, + "state": "accepted", + "reason": "vendor patch ETA 2026-06-01; mitigated by WAF rule waf-12", + "expires_at": 1719792000000, + "assignee": "platform-team", + "mitigated_by": null, + "updated_by": "alice@example.com" + }, + { + "event_at": 1715126000000, + "state": "in_progress", + "reason": null, + "expires_at": null, + "assignee": "platform-team", + "mitigated_by": null, + "updated_by": "bob@example.com" + } + ] +} +``` + +Rows are ordered `event_at DESC`. Optional fields are `null` when not set on that mutation. A `clear_finding_state` call appears as a row with `state="cleared"`. + +##### `query_epss_history` + +Request: + +```json +{ + "cve": "CVE-2024-12345", + "days": 90 +} +``` + +`days` defaults to `90` and is capped at `365`. Snapshots are captured by the daily Update tick for any CVE the org has open findings on; CVEs with no historical coverage in the requested window return an empty `history` array. + +Response: + +```json +{ + "history": [ + { "snapshot_date": "2026-02-08", "score": 0.0123, "percentile": 0.42 }, + { "snapshot_date": "2026-02-09", "score": 0.0145, "percentile": 0.45 } + ] +} +``` + +Rows are ordered `snapshot_date ASC` so they can be plotted directly. See [EPSS history](#epss-history) for the data-collection model and the reference consumer. The full request and response schemas live in the extension's `requestSchema()` declaration in [`refractionPOINT/ext-vulnerability-reporting`](https://github.com/refractionPOINT/ext-vulnerability-reporting). The web app's Vulnerabilities page is a reference consumer for every action listed above. +### Row schema additions + +The list-style actions return rows whose schema has been extended in this round. The fields below are additive — existing fields and types are unchanged. + +#### `query_host_vuln_packages` rows + +| Field | Type | Notes | +|-------|------|-------| +| `fix_version` | string, optional | Smallest known version that fixes the CVE for this package. Derived from NVD `configurations.cpeMatch.versionEndExcluding` matched against the row's package. Absent when the NVD entry does not pin a fixed version (kernel-class CVEs, hardware advisories, etc.). | +| `lc_risk` | int (0-100) | Server-authoritative LC Risk score for this `(sensor, package, CVE)` row, computed at scan time. See [LC Risk](#lc-risk). | + +#### `query_cves` rows + +| Field | Type | Notes | +|-------|------|-------| +| `max_lc_risk` | int (0-100) | Maximum LC Risk across all affected hosts in the org for this CVE. See [LC Risk](#lc-risk). | + +#### Sort options + +Both `query_cves` and `query_host_vuln_packages` accept a new `sort_by` value: + +| Action | New `sort_by` | Sorts by | +|--------|---------------|----------| +| `query_cves` | `lc_risk` | `max_lc_risk DESC` | +| `query_host_vuln_packages` | `lc_risk` | `lc_risk DESC` | + +`lc_risk` is the canonical default for prioritization views in the web app; pass it explicitly when calling the API directly to match the UI ordering. + +## 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` — using: + +- The row's CVSS severity (`critical` / `high` / `medium` / `low`). +- The CVE's EPSS percentile, fetched from the `/enrich` integration. +- 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](../../../2-sensors-deployment/asset-tags.md). + +The score is persisted on `vuln_reports.lc_risk` (per-host, per-package, per-CVE) and rolled up to `vuln_cve_counts.max_lc_risk` (org-wide max for the CVE), so list-view sorts and filters do not have to recompute it on every read. + +Persisting the score makes LC Risk usable as a stable sort key — the same number drives the API rows and the UI columns, and the value does not drift between reads as enrichment data refreshes. The frontend uses an identical hardcoded calculation (see `src/utils/lcRisk.ts` in `web-app-frontend`) for client-side previews; the two implementations are intentionally kept in sync as a hardcoded formula in both repos rather than via a shared service, so the UI can display the score without an extra round trip. + +The formula is: + +```text +base = SEVERITY_RANK[severity] / 4 * 60 +base += epss_percentile * 25 (or 0 if epss is null) +base += 15 if in_kev else 0 +lc_risk = clamp(base * criticality_mult, 0, 100) + +CRITICALITY_MULT: critical=1.6, high=1.3, medium=1.0, low=0.8 +``` + +`SEVERITY_RANK` maps `critical=4`, `high=3`, `medium=2`, `low=1`. A row with no severity gets `0` for the severity term. A row whose host has no `lc:asset:criticality:*` tag uses `medium` (multiplier `1.0`) as the default bucket. + +## EPSS history + +EPSS percentile is not a fixed property of a CVE — it drifts as new exploit telemetry lands. The extension captures a daily snapshot for each CVE the org has at least one open finding on; the snapshot is taken by the same daily Update tick that refreshes the live KEV and EPSS feeds. + +The captured series is exposed via [`query_epss_history`](#query_epss_history) and rendered on the CVE detail page in the web app as a 90-day sparkline next to the live percentile. The default 90-day window matches the sparkline; pass a larger `days` value (up to 365) to backfill a longer view for reports. + +CVEs that the org has never had an open finding on are not retained — there is no historical series to query for them. CVEs whose findings have all been remediated retain whatever was captured during their open period and stop accruing new snapshots after the last open finding closes. + ## Web UI The web app's Vulnerabilities section is the primary surface. It mirrors the API: - **Filter chip-bar** — multi-select chips for severity, criticality, exposure, environment, compliance regime, KEV match, EPSS percentile, and lifecycle state. - **KPI strip** — top-line counts of total findings, KEV-matched findings, SLA-breached findings, and accepted exceptions nearing expiry. -- **Endpoints / CVEs / Packages** tabs — each with sortable columns, KEV and EPSS badges, and the LC Risk score (a composite of severity, criticality, exposure, KEV membership, and EPSS percentile). +- **Endpoints / CVEs / Packages** tabs — each with sortable columns, KEV and EPSS badges, an `lc_risk` column (the persisted [LC Risk](#lc-risk) score), and a `fix_version` column on the per-host packages tab when one is available. - **Asset detail page** — per-sensor view of vulnerable packages, asset metadata projection, and recent state changes. -- **CVE detail page** — affected hosts, affected packages, full CVE description, references, and the merged KEV/EPSS view. +- **CVE detail page** — affected hosts, affected packages, full CVE description, references, the merged KEV/EPSS view, and a 90-day EPSS sparkline backed by [`query_epss_history`](#query_epss_history). - **Reports** — exec summary, compliance scope (driven by `lc:asset:compliance:*`), and a remediation report keyed off SLA windows. Screenshots are deferred for this revision while the UI continues to land in production. They will be added in a follow-up once the surfaces stabilize. +## 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.) and is tracked as a future capability rather than a near-term deliverable. + +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. Customers that need true reachability today should treat the criticality multiplier as the interim signal and watch the changelog for the dedicated capability. + ## See Also - [`lc:asset:*` Tag Namespace](../../../2-sensors-deployment/asset-tags.md) — Asset metadata convention consumed by this extension From f431a5c0e1fde83c3cf1a3f5c762c7f1d2d22ae2 Mon Sep 17 00:00:00 2001 From: Maxime Date: Sat, 9 May 2026 20:22:17 -0700 Subject: [PATCH 3/5] docs(vuln-uplift): full Vulnerability Reporting reference, workflows, and best practices Pivot the page from a Phase-1/2 milestone-keyed reference into an operator-facing extension manual: complete API action reference for every read/write action including request/response shapes, concept sections for lifecycle states / fingerprints / LC Risk / KEV / EPSS / daily snapshots / the daily Update tick, ten numbered operator workflows (triage, acceptance, mitigated-by-DR, compliance export, audit-log review, EPSS trend monitoring, bulk ops), a real best-practices section (state choice decision tree, tag combos, LC Risk vs CVSS, mitigated_by wiring, SLA configuration, daily-tick expectations, downstream Output wiring), and a glossary. Flagged the backend/frontend `low` criticality multiplier mismatch (0.6 vs 0.8) inline. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../limacharlie/vulnerability-reporting.md | 824 ++++++++++++++---- 1 file changed, 666 insertions(+), 158 deletions(-) diff --git a/docs/5-integrations/extensions/limacharlie/vulnerability-reporting.md b/docs/5-integrations/extensions/limacharlie/vulnerability-reporting.md index 6ab0bbac..f7a517d1 100644 --- a/docs/5-integrations/extensions/limacharlie/vulnerability-reporting.md +++ b/docs/5-integrations/extensions/limacharlie/vulnerability-reporting.md @@ -1,19 +1,19 @@ # 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, and surfaces the results in the LimaCharlie web app and via the extension API. +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 lifecycle state 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](../../../2-sensors-deployment/asset-tags.md): asset criticality, exposure, environment, owner, and compliance tags are read directly off the sensors and used to prioritize findings, scope filters, and parameterize remediation SLAs. ## 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. The resolver caches aggressively to keep per-call cost low. -3. **Enrichment.** The extension joins each CVE against CISA KEV (Known Exploited Vulnerabilities) and FIRST EPSS (Exploit Prediction Scoring System) at read time. Enrichment data refreshes daily. -4. **Surfacing.** Findings are exposed via: - - The LimaCharlie web app's Vulnerabilities page (filter chip-bar, KPI strip, KEV/EPSS columns, LC Risk score, lifecycle state chips, routable CVE / asset detail pages, and exec / compliance / remediation reports). - - The extension request API, listed under [API Actions](#api-actions). +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](#lc-risk) score that is persisted on every finding row. +4. **Lifecycle.** Every finding has a state (open / in_progress / mitigated / accepted / false_positive) plus an audit log of every transition. State is keyed by a deterministic [fingerprint](#finding-fingerprint) so it survives rescans. +5. **Daily scans.** A per-org daily tick runs four jobs: KEV-match emission, SLA-breach warning, 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](#api-actions)). -The extension is stateless aside from the per-org Spanner-backed `vuln_reports` store that holds the most recent inventory and resolved CVE set per sensor. +The extension is stateless aside from the per-org Spanner-backed tables (`vuln_reports`, `vuln_finding_state`, `vuln_finding_state_history`, `vuln_daily_snapshots`, `vuln_epss_history`, plus rollup tables) and a small `org_value` keyed at `ext_vuln_kev_known_set`. ## Setup @@ -21,11 +21,13 @@ Navigate to the [Vulnerability Reporting extension page](https://app.limacharlie 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`). This is the right default for almost every customer. +- **`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 and you simply want every report processed. +- **`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. +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](#events-emitted) have a destination on the event stream. !!! info "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. @@ -59,18 +61,18 @@ The configuration is edited on the extension page in the LimaCharlie web app. Al } ``` -The `criticality_tag_overrides` map is consulted only when a sensor carries no canonical `lc:asset:criticality:*` tag. Explicit canonical tags always win, so an organization can migrate gradually: leave the override map in place, start applying canonical tags to the most important assets first, and remove the override entries as coverage grows. Override values must be canonical buckets; any other value is rejected at write time. +`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. `sla_windows_hours` keys must be canonical buckets and values must be positive integers; partial maps are valid (unset keys fall back to defaults). ## Asset metadata The extension reads sensor tags in the [`lc:asset:*` namespace](../../../2-sensors-deployment/asset-tags.md) and uses them to: - **Prioritize findings.** `lc:asset:criticality:*` is the multiplier in the LC Risk score and the source for the per-criticality SLA window. -- **Scope filters.** `lc:asset:env:*` and `lc:asset:exposure:*` populate filter chips on the Vulnerabilities page and feed report filters. -- **Surface compliance views.** `lc:asset:compliance:*` is multi-value; an asset can carry several regimes and shows up in each. -- **Route assignments.** `lc:asset:owner:*` is exposed on the asset detail page so workflows downstream of the extension (Cases, Outputs to Slack/Jira/etc.) have the routing target available. +- **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 a query that returns endpoints (`query_endpoints`, `query_cve_vuln_hosts`, `query_host_vuln_packages`), each row carries an `asset_metadata` projection of the parsed tags: +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: ```json { @@ -86,80 +88,165 @@ When `include_tags=true` is passed to a query that returns endpoints (`query_end } ``` -Tags with malformed values for the closed-set fields (`criticality`, `exposure`, `env`) are dropped by the parser and do not appear in `asset_metadata`. See [Asset Tag Namespace](../../../2-sensors-deployment/asset-tags.md) for the full schema and validation rules. - -## KEV and EPSS enrichment +Tags with malformed values for the closed-set fields (`criticality`, `exposure`, `env`) are dropped by the parser. See [Asset Tag Namespace](../../../2-sensors-deployment/asset-tags.md) for the full schema. -The extension augments every CVE with two external feeds at read time: +## Concepts -- **[CISA KEV](https://www.cisa.gov/known-exploited-vulnerabilities-catalog)** — the U.S. Cybersecurity and Infrastructure Security Agency's catalog of vulnerabilities with confirmed exploitation in the wild. Membership is a strong signal to prioritize remediation. -- **[FIRST EPSS](https://www.first.org/epss/)** — the Exploit Prediction Scoring System, a probabilistic score (0.0–1.0) and percentile estimating the likelihood that a CVE will be exploited in the next 30 days. +### Lifecycle states -Both feeds are refreshed daily. Resolution and enrichment data are served by `cve.limacharlie.io`; the extension's `/enrich` integration merges them into the response. +Every finding has a `state` field that tracks where it is in the remediation pipeline. State is keyed by a [fingerprint](#finding-fingerprint), so it survives rescans and re-resolutions. -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. +| State | Description | Required fields | Counts against SLA | +|-------|-------------|-----------------|--------------------| +| `open` | New finding (implicit when no state row exists). | — | Yes | +| `in_progress` | Remediation is underway. | — | Yes | +| `mitigated` | Compensating control in place; finding is no longer counted as exploitable. Sets `remediated_at` (used by MTTR). | optional `mitigated_by` | No | +| `accepted` | Risk has been formally accepted as an exception. | `reason`, `expires_at` (must be in the future) | No, until `expires_at` lapses | +| `false_positive` | Confirmed not applicable. | `reason` | No | -When enrichment is included: +State changes record `event_at`, `state`, optional `reason`, `expires_at`, `assignee`, `mitigated_by`, and `updated_by`. A `clear_finding_state` call appends a row with `state="cleared"` so resets are themselves first-class events. -- KEV match: each affected CVE in the response carries a `kev` block with the date the CVE was added, the required action, and the due date if specified. -- EPSS score: each CVE carries an `epss` block with the probability and the percentile. -- Exploit references: `query_cve` (single-CVE detail) returns an `exploit_refs` array. The list-view actions deliberately omit exploit refs to keep page payloads small. +#### Lapsed accepted (`expired=true`) -## Lifecycle workflow +`accepted` is the only state with a required expiry. When `expires_at` is in the past at read time the rendered state flips to `open` and the row is flagged with `expired=true` so the UI can surface the lapsed exception. The Spanner row itself is **not** mutated — auditors can still see when and why the exception was originally granted. -!!! warning "Phase 2" - The lifecycle workflow described in this section ships in Phase 2 of the vulnerability-management uplift. State, exception, and event handling may not be enabled in production at the time you read this; the API actions and event names listed below are the contract Phase 2 will land against. Phase 1 (the inventory, resolution, enrichment, and asset metadata pipeline above) is live independently. +#### `mitigated_by` validation -Each finding has a state field that tracks where it is in the remediation pipeline: +When `set_finding_state` is called with `state="mitigated"`, the optional `mitigated_by` field accepts the key of a D&R rule that provides the compensating control. The extension validates the supplied key against `dr-general`, `dr-managed`, and `dr-service` at write time. Unknown keys are rejected; there is no soft-fail mode that records a warning. -| State | Description | -|-------|-------------| -| `open` | New finding. Counts against SLA. | -| `in_progress` | A remediation effort is underway. | -| `mitigated` | Compensating control in place; the finding is no longer counted as exploitable. | -| `accepted` | Risk has been formally accepted as an exception. Requires a reason and an expiry. | -| `false_positive` | Confirmed not applicable to the asset (e.g. version detection misfire). | +#### Scope (org vs host) -### Accepted exceptions +State rows are written at one of two scopes: -`accepted` is the only state that carries a required expiry. An accepted finding stops counting against the SLA until the expiry passes. Accepted findings whose expiry has lapsed automatically revert to `open` at read time, and the finding is flagged as `expired_exception=true` in the response so audit reports can show that an exception ran out without being renewed. +- **`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"). -State changes are recorded in an audit trail; see [Audit log](#audit-log) below. +Read-overlay precedence: a host-scope state row beats an org-scope one for the matching row. Both use the same fingerprint algorithm; the host fingerprint mixes in the `sid`. -### `mitigated_by` validation +### Finding fingerprint -When `set_finding_state` is called with `state="mitigated"`, the optional `mitigated_by` field accepts the key of a D&R rule that provides the compensating control. The extension validates the supplied key against the org's D&R hive at write time. Unknown rule keys are rejected with HTTP 400. There is no soft-fail mode that records a warning and accepts the write — the constraint is hard, so a stale or typo'd rule reference cannot land in the audit trail. +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: -### Audit log +- **Org scope:** `SHA-256(cve + "\x00" + normalized_package_name)` +- **Host scope:** `SHA-256(cve + "\x00" + sid + "\x00" + normalized_package_name)` -Every state mutation appends a row to the `vuln_finding_state_history` table. The row records `event_at`, the resulting `state`, and the optional `reason`, `expires_at`, `assignee`, `mitigated_by`, and `updated_by` values that were supplied with the mutation. The `clear_finding_state` action also appends a row, with `state="cleared"`, so a reset is itself a first-class event in the trail and is not silently elided. +`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. -The read path is the [`list_finding_state_history`](#api-actions) action. Compliance audits looking for the trail of acceptances and rationales — "who accepted CVE-2024-XXXXX on March 12, with what reason, and until when?" — query that action per finding. The history is returned ordered `event_at DESC` so the most recent mutation is first. +### LC Risk -### Events emitted +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. -The extension emits the following events through LimaCharlie's standard event surface. Customers route them via Outputs to Jira, Slack, ext-cases, PagerDuty, etc. +#### Inputs -| Event | When fired | -|-------|-----------| -| `vuln_finding.created` | A new finding lands for an asset. | -| `vuln_finding.kev_match` | A finding is enriched with a CISA KEV match. Fired in addition to `vuln_finding.created` so high-priority alerting can subscribe to it directly. | -| `vuln_finding.state_changed` | The finding's state moves between any two values. Includes the prior state, new state, actor, and reason. | -| `vuln_finding.sla_breach_warning` | The finding is approaching its SLA window. Fires once at 80% of the window elapsed and again when the window is breached. | +- 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](../../../2-sensors-deployment/asset-tags.md). -#### Example output configuration +#### Formula -The same Outputs surface used for detections can route vulnerability events. To send `vuln_finding.kev_match` to Slack: +```text +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) -```yaml -name: vuln-kev-to-slack -module: slack -type: event -filter_event_type: vuln_finding.kev_match -slack_api_token: hive://secret/slack-webhook -slack_channel: "#vuln-priority" +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`. + +!!! warning "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](#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 state overlay (suppressing rows whose effective state is `mitigated` / `accepted` / `false_positive`), 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. Findings whose effective state is one of the suppressed states are excluded by design — the snapshot represents "what the operator still owes," not "every CVE the resolver ever returned." + +### 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 four scans sequentially with an independent 60-second 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 | `sla_breach` | Emits `vuln_finding.sla_breach_warning` for findings whose age > `sla_window - 3 days` AND state is not in the suppressed set. | +| 3 | `daily_snapshot` | Writes the per-severity open / KEV counts for today (see [Daily snapshots](#daily-snapshots)). | +| 4 | `epss_history` | Writes one EPSS row per distinct org CVE for today (see [EPSS history](#epss-history-90-day-series)). | + +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": {"": {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 (`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: @@ -173,100 +260,201 @@ curl -s -X POST \ -d data='' ``` -### Phase 1 (live) +The full request and response schemas live in the extension's `requestSchema()` declaration in [`refractionPOINT/ext-vulnerability-reporting`](https://github.com/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, or severity. KEV/EPSS optional. | +| `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 powering the Vulnerabilities dashboard. | -| `query_host_vuln_packages` | All vulnerable packages and their CVEs for one sensor. | +| `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 that carry each pair. | +| `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 time-series for a CVE. See [EPSS history](#epss-history). | +| `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_states` | Page through `vuln_finding_state` for the org with optional scope / state / assignee filters. | +| `list_finding_state_history` | Audit log of state mutations for one finding. | + +### Write actions + +| Action | Purpose | +|--------|---------| | `scan_packages` | Trigger an out-of-band `os_packages` scan against a specific sensor. | +| `set_finding_state` | Move one finding to a new state. | +| `clear_finding_state` | Reset a finding to implicit `open` (deletes the row, audit-logs a `cleared` event). | +| `bulk_set_finding_state` | Apply a state change across up to 100 findings in one call. | -### Phase 2 (lifecycle — see caveat above) +### Internal action | Action | Purpose | |--------|---------| -| `set_finding_state` | Move one finding to a new state. Accepts `reason`, `ticket`, `assignee`, `mitigated_by` (validated — see [`mitigated_by` validation](#mitigated_by-validation)), and (for `accepted`) `expires_at`. The response echoes `first_seen_at` and `remediated_at` so callers do not need a follow-up read. | -| `clear_finding_state` | Reset a finding to `open`, discarding the current state. The reset is recorded in the audit log as a `state="cleared"` event. | -| `bulk_set_finding_state` | Apply a state change across many findings in one call. Each per-item response also echoes `first_seen_at` and `remediated_at`. | -| `list_finding_state_history` | Read the audit log of state mutations for one finding. See [Audit log](#audit-log). | +| `process_packages` | Internal callback fired by the ingest D&R rule. Not user-facing. | -#### Action request shapes +### Action reference -##### `list_finding_state_history` +#### `query_cves` -Request — finding-by-key form: +Request: ```json { - "scope": "host", - "cve": "CVE-2024-12345", - "normalized_package_name": "openssl", - "sid": "550e8400-e29b-41d4-a716-446655440000", - "limit": 100 + "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 } ``` -Request — fingerprint form (use the fingerprint returned by the row-level read actions): +Response: ```json { - "fingerprint": "a3f2…", - "limit": 100 + "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 }, + "state": null + } + ], + "next_cursor": "100", + "total_return_count": 1 } ``` -`scope` is `host` for per-host findings (and requires `sid`) and `org` for org-wide findings (no `sid`). `limit` defaults to the extension's standard page size and is capped at the standard maximum. +Filters supported: `severity`, `kev_only`, `epss_min`, `state`, `assignee`, `criticality`, `env`, `exposure`. `kev_only` and `epss_min` force-enable `include_enrichment`. + +#### `query_endpoints` + +Request: + +```json +{ + "limit": 50, + "sort_by": "hostname", + "sort_asc": true, + "filters": { "platform_string": ["linux"], "criticality": ["critical"] }, + "include_tags": true +} +``` Response: ```json { - "history": [ - { - "event_at": 1715212345000, - "state": "accepted", - "reason": "vendor patch ETA 2026-06-01; mitigated by WAF rule waf-12", - "expires_at": 1719792000000, - "assignee": "platform-team", - "mitigated_by": null, - "updated_by": "alice@example.com" - }, + "endpoints": [ { - "event_at": 1715126000000, - "state": "in_progress", - "reason": null, - "expires_at": null, - "assignee": "platform-team", - "mitigated_by": null, - "updated_by": "bob@example.com" + "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 } ``` -Rows are ordered `event_at DESC`. Optional fields are `null` when not set on that mutation. A `clear_finding_state` call appears as a row with `state="cleared"`. +#### `query_host_vuln_packages` -##### `query_epss_history` +Request: + +```json +{ + "sid": "550e8400-...", + "sort_by": "lc_risk", + "sort_asc": false, + "limit": 100, + "filters": { "severity": ["critical", "high"] } +} +``` + +Response (one row per `(package_name, package_version, cve)`): + +```json +{ + "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 }, + "state": null + } + ], + "cursor": "", + "total": 1 +} +``` + +#### `query_cve_vuln_hosts` Request: ```json { "cve": "CVE-2024-12345", - "days": 90 + "normalized_package_name": "openssl", + "include_tags": true, + "limit": 100 } ``` -`days` defaults to `90` and is capped at `365`. Snapshots are captured by the daily Update tick for any CVE the org has open findings on; CVEs with no historical coverage in the requested window return an empty `history` array. +Pass `normalized_package_name` so the state overlay can compute host-scope fingerprints; without it, only org-scope state hits land. Filters supported: `platform`, `platform_string`, `criticality`, `env`, `exposure`, `state`, `assignee`. + +#### `query_cve` + +Request: + +```json +{ "cve_id": "CVE-2024-12345", "include_enrichment": true } +``` Response: +```json +{ + "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: + +```json +{ "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: + ```json { "history": [ @@ -276,95 +464,415 @@ Response: } ``` -Rows are ordered `snapshot_date ASC` so they can be plotted directly. See [EPSS history](#epss-history) for the data-collection model and the reference consumer. +CVEs with no historical coverage in the requested window return `{ "history": [] }`. -The full request and response schemas live in the extension's `requestSchema()` declaration in [`refractionPOINT/ext-vulnerability-reporting`](https://github.com/refractionPOINT/ext-vulnerability-reporting). The web app's Vulnerabilities page is a reference consumer for every action listed above. +#### `query_daily_snapshots` -### Row schema additions +Request: -The list-style actions return rows whose schema has been extended in this round. The fields below are additive — existing fields and types are unchanged. +```json +{ "days": 30, "severities": ["critical"] } +``` -#### `query_host_vuln_packages` rows +`days` defaults to `30`, capped at `365`. `severities` defaults to all four canonical buckets. Response is ordered `(snapshot_date ASC, severity ASC)`: -| Field | Type | Notes | -|-------|------|-------| -| `fix_version` | string, optional | Smallest known version that fixes the CVE for this package. Derived from NVD `configurations.cpeMatch.versionEndExcluding` matched against the row's package. Absent when the NVD entry does not pin a fixed version (kernel-class CVEs, hardware advisories, etc.). | -| `lc_risk` | int (0-100) | Server-authoritative LC Risk score for this `(sensor, package, CVE)` row, computed at scan time. See [LC Risk](#lc-risk). | +```json +{ + "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 } + ] +} +``` -#### `query_cves` rows +#### `set_finding_state` -| Field | Type | Notes | -|-------|------|-------| -| `max_lc_risk` | int (0-100) | Maximum LC Risk across all affected hosts in the org for this CVE. See [LC Risk](#lc-risk). | +Request: -#### Sort options +```json +{ + "scope": "host", + "cve": "CVE-2024-12345", + "normalized_package_name": "openssl", + "sid": "550e8400-...", + "state": "accepted", + "reason": "Vendor patch ETA 2026-06-01; mitigated by WAF rule waf-12.", + "expires_at": "2026-06-15T00:00:00Z", + "assignee": "platform-team" +} +``` -Both `query_cves` and `query_host_vuln_packages` accept a new `sort_by` value: +Required fields: `scope` (`org` | `host`), `cve`, `normalized_package_name`, `state`. `sid` is required when `scope=host`. `state=accepted` requires `reason` plus a future `expires_at` (RFC3339). `state=false_positive` requires `reason`. -| Action | New `sort_by` | Sorts by | -|--------|---------------|----------| -| `query_cves` | `lc_risk` | `max_lc_risk DESC` | -| `query_host_vuln_packages` | `lc_risk` | `lc_risk DESC` | +Response: -`lc_risk` is the canonical default for prioritization views in the web app; pass it explicitly when calling the API directly to match the UI ordering. +```json +{ + "fingerprint": "a3f2…", + "state": { + "scope": "host", + "fingerprint": "a3f2…", + "state": "accepted", + "reason": "Vendor patch ETA 2026-06-01; mitigated by WAF rule waf-12.", + "expires_at": "2026-06-15T00:00:00Z", + "assignee": "platform-team", + "first_seen_at": "2026-04-30T11:22:00Z", + "remediated_at": null, + "updated_by": "alice@example.com", + "created_at": "2026-05-09T18:30:00Z", + "updated_at": "2026-05-09T18:30:00Z" + } +} +``` -## LC Risk +`first_seen_at` is seeded from the earliest matching `vuln_reports.created_at` on initial insert and preserved across updates. `remediated_at` is set to wall-clock time when state transitions **into** `mitigated` and cleared when state transitions out, so MTTR can be derived from `(remediated_at - first_seen_at)`. -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` — using: +#### `clear_finding_state` -- The row's CVSS severity (`critical` / `high` / `medium` / `low`). -- The CVE's EPSS percentile, fetched from the `/enrich` integration. -- 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](../../../2-sensors-deployment/asset-tags.md). +Either supply `fingerprint` directly or `(scope, cve, normalized_package_name [, sid])`: + +```json +{ "scope": "host", "cve": "CVE-2024-12345", "normalized_package_name": "openssl", "sid": "550e8400-..." } +``` -The score is persisted on `vuln_reports.lc_risk` (per-host, per-package, per-CVE) and rolled up to `vuln_cve_counts.max_lc_risk` (org-wide max for the CVE), so list-view sorts and filters do not have to recompute it on every read. +Returns `{ "cleared": true }` and appends a `state="cleared"` row to the audit log. A clear against an absent row still appends the audit row — explicit clears are a signal worth recording. -Persisting the score makes LC Risk usable as a stable sort key — the same number drives the API rows and the UI columns, and the value does not drift between reads as enrichment data refreshes. The frontend uses an identical hardcoded calculation (see `src/utils/lcRisk.ts` in `web-app-frontend`) for client-side previews; the two implementations are intentionally kept in sync as a hardcoded formula in both repos rather than via a shared service, so the UI can display the score without an extra round trip. +#### `bulk_set_finding_state` -The formula is: +Apply up to 100 set-state items in one call. Each item is committed independently; partial successes are reported per-item. -```text -base = SEVERITY_RANK[severity] / 4 * 60 -base += epss_percentile * 25 (or 0 if epss is null) -base += 15 if in_kev else 0 -lc_risk = clamp(base * criticality_mult, 0, 100) +```json +{ + "items": [ + { "scope": "org", "cve": "CVE-2024-12345", "normalized_package_name": "openssl", "state": "mitigated", "mitigated_by": "dr-rule-waf-12" }, + { "scope": "org", "cve": "CVE-2024-22222", "normalized_package_name": "openssl", "state": "in_progress", "assignee": "platform-team" } + ] +} +``` + +Response: + +```json +{ + "applied": 2, + "results": [ + { "index": 0, "fingerprint": "a3f2…", "state": { "...": "..." } }, + { "index": 1, "fingerprint": "b771…", "state": { "...": "..." } } + ], + "errors": [] +} +``` + +Items over 100 reject the entire call with HTTP 400. + +#### `list_finding_states` + +Page through state rows in the org. All filters are optional and stack as AND. + +```json +{ "scope": "host", "state": ["accepted", "in_progress"], "assignee": ["platform-team"], "limit": 100, "cursor": "" } +``` + +Response: `{ "results": [, ...], "next_cursor": "..." }`. `limit` defaults to 100; the response is ordered `(scope ASC, fingerprint ASC)`. -CRITICALITY_MULT: critical=1.6, high=1.3, medium=1.0, low=0.8 +#### `list_finding_state_history` + +Either supply `fingerprint` directly or the canonical inputs: + +```json +{ "scope": "host", "cve": "CVE-2024-12345", "normalized_package_name": "openssl", "sid": "550e8400-...", "limit": 100 } ``` -`SEVERITY_RANK` maps `critical=4`, `high=3`, `medium=2`, `low=1`. A row with no severity gets `0` for the severity term. A row whose host has no `lc:asset:criticality:*` tag uses `medium` (multiplier `1.0`) as the default bucket. +Response (ordered `event_at DESC`, default limit 50, max 500): -## EPSS history +```json +{ + "history": [ + { + "event_at": "2026-05-09T18:30:00Z", + "state": "accepted", + "reason": "Vendor patch ETA 2026-06-01; mitigated by WAF rule waf-12.", + "expires_at": "2026-06-15T00:00:00Z", + "assignee": "platform-team", + "updated_by": "alice@example.com" + }, + { + "event_at": "2026-05-08T14:02:00Z", + "state": "in_progress", + "assignee": "platform-team", + "updated_by": "bob@example.com" + } + ] +} +``` + +A `clear_finding_state` call appears as a row with `state="cleared"`. `event_at` is the Spanner commit timestamp — the writer never accepts a caller-supplied event time. + +#### `scan_packages` + +Trigger an out-of-band scan for one sensor: -EPSS percentile is not a fixed property of a CVE — it drifts as new exploit telemetry lands. The extension captures a daily snapshot for each CVE the org has at least one open finding on; the snapshot is taken by the same daily Update tick that refreshes the live KEV and EPSS feeds. +```json +{ "sid": "550e8400-..." } +``` + +Returns immediately; the scan completes asynchronously when the sensor reports back via the ingest D&R rule. + +## Events emitted -The captured series is exposed via [`query_epss_history`](#query_epss_history) and rendered on the CVE detail page in the web app as a 90-day sparkline next to the live percentile. The default 90-day window matches the sparkline; pass a larger `days` value (up to 365) to backfill a longer view for reports. +The extension emits the following events through LimaCharlie's standard webhook adapter. Customers route them via Outputs to Jira, Slack, Cases, PagerDuty, etc. -CVEs that the org has never had an open finding on are not retained — there is no historical series to query for them. CVEs whose findings have all been remediated retain whatever was captured during their open period and stop accruing new snapshots after the last open finding closes. +| 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.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_state` / `bulk_set_finding_state` succeeded. | `fingerprint`, `state` (full FindingState including `first_seen_at` / `remediated_at`) | +| `vuln_finding.sla_breach_warning` | Finding age > `sla_window - 3 days` AND state is not in the suppressed set. Fires once per finding per daily tick. | `cve`, `severity`, `sid`, `hostname`, `first_seen`, `extra.criticality`, `extra.days_to_deadline` | + +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. To send `vuln_finding.kev_match` to Slack: + +```yaml +name: vuln-kev-to-slack +module: slack +type: event +filter_event_type: vuln_finding.kev_match +slack_api_token: hive://secret/slack-webhook +slack_channel: "#vuln-priority" +``` ## Web UI The web app's Vulnerabilities section is the primary surface. It mirrors the API: -- **Filter chip-bar** — multi-select chips for severity, criticality, exposure, environment, compliance regime, KEV match, EPSS percentile, and lifecycle state. -- **KPI strip** — top-line counts of total findings, KEV-matched findings, SLA-breached findings, and accepted exceptions nearing expiry. -- **Endpoints / CVEs / Packages** tabs — each with sortable columns, KEV and EPSS badges, an `lc_risk` column (the persisted [LC Risk](#lc-risk) score), and a `fix_version` column on the per-host packages tab when one is available. -- **Asset detail page** — per-sensor view of vulnerable packages, asset metadata projection, and recent state changes. -- **CVE detail page** — affected hosts, affected packages, full CVE description, references, the merged KEV/EPSS view, and a 90-day EPSS sparkline backed by [`query_epss_history`](#query_epss_history). -- **Reports** — exec summary, compliance scope (driven by `lc:asset:compliance:*`), and a remediation report keyed off SLA windows. +- **KPI strip** — Total findings, KEV in environment, Open critical, Critical assets. +- **Trend tiles** — MTTR by severity (driven by `remediated_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`), lifecycle state, and assignee. +- **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, lifecycle state chip, asset-metadata cell. Bulk-select on the CVEs tab opens `SetStateModal` for batch lifecycle 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`, lifecycle history popover, 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 recent state changes. +- **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 ``, ``, 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](https://app.limacharlie.io/add-ons/extension-detail/ext-vulnerability-reporting) (default `scan_mode: scheduled`). +2. Tag your sensors. At minimum `lc:asset:criticality:*`; ideally also `exposure`, `env`, `owner`, and `compliance`. Mass-tag by selector: + ```bash + 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: + ```bash + 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":""}' + ``` +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 D&R rule already covers it → **Set state → mitigated**, attach `mitigated_by` (D&R rule key). The audit log records who/when. + - Otherwise → **Set state → in_progress**, attach `assignee`. +5. Optional: open the **Lifecycle history** popover on the CVE/host row to confirm the transition is recorded. + +### 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 `remediated_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. Acceptance / risk acceptance + +When a finding cannot be patched in time: + +1. CVE row → **Set state → accepted**. +2. Fill in `reason` (free-form) and `expires_at` (must be in the future). Optional `assignee` for routing on lapse. +3. The finding stops counting against the SLA until `expires_at`. At that moment it auto-reverts to `open` at read time and the row is surfaced with `expired=true` so the operator knows to revisit. +4. To extend, set state again with a new `expires_at`. To formally close, transition to `mitigated` once the patch lands. + +The audit log records every transition including the lapsed-and-renewed case, so compliance auditors can see "accepted by Alice on 2026-03-12 with reason X, expired 2026-04-12, renewed by Bob with reason Y." + +### 5. Mitigated by D&R rule + +When exploitation is stopped via a detection rule: + +1. Confirm the D&R rule's hive key — typically in `dr-general` or `dr-managed`. +2. Set state: + ```bash + curl -s -X POST "$LC_API/v1/extension/request/ext-vulnerability-reporting" \ + -H "Authorization: Bearer $LC_JWT" \ + -d oid="$OID" -d action="set_finding_state" \ + -d data='{ + "scope": "org", + "cve": "CVE-2024-12345", + "normalized_package_name": "openssl", + "state": "mitigated", + "mitigated_by": "dr-rule-waf-12" + }' + ``` +3. The extension validates the rule key against `dr-general`, `dr-managed`, and `dr-service`; an unknown key is rejected with a 400. +4. `remediated_at` is set on the row; MTTR begins counting toward the eventual stat. + +### 6. 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. State evidence is included per finding: `accepted` rows surface as "Risk accepted" with the rationale and expiry; `mitigated` rows surface with the `mitigated_by` rule key. +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. + +### 7. 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 state → `mitigated` (host scope) for findings the host has individually patched. + +### 8. Audit-log review + +1. On any finding's CVE detail page, click the **Lifecycle history** popover. +2. Or via API: + ```bash + curl -s -X POST "$LC_API/v1/extension/request/ext-vulnerability-reporting" \ + -H "Authorization: Bearer $LC_JWT" \ + -d oid="$OID" -d action="list_finding_state_history" \ + -d data='{ + "scope": "host", + "cve": "CVE-2024-12345", + "normalized_package_name": "openssl", + "sid": "550e8400-...", + "limit": 100 + }' + ``` +3. Rows are returned `event_at DESC`. Every transition is captured including `cleared` resets. + +### 9. 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 whose state is still `open`. +3. For longer reports, raise `days` (capped at 365): + ```json + { "cve": "CVE-2024-12345", "days": 365 } + ``` + +### 10. Bulk operations + +1. CVEs tab → multi-select rows. +2. Bulk action → **Set state** opens `SetStateModal` 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[]` (with `first_seen_at` and `remediated_at`) directly — no follow-up `list_finding_states` round trip is needed. + +## Best practices + +### Choosing a lifecycle state + +| Situation | State | +|-----------|-------| +| Detected, no work yet started | (leave implicit `open`) | +| Ticket assigned, patching in flight | `in_progress` (with `assignee`) | +| D&R rule blocks exploitation; patch coming later | `mitigated` (with `mitigated_by`) | +| Patch lands, rescan confirms gone | (leave to natural attrition; the rescan removes the row) | +| Cannot patch by the SLA window, business-accepted exception | `accepted` (with `reason` + future `expires_at`) | +| Resolver false positive (wrong product / wrong version) | `false_positive` (with `reason`) | + +Do not use `mitigated` for "patch in progress" — `remediated_at` is set on entry to `mitigated`, which would skew MTTR. Use `in_progress` until the patch lands or a compensating control is documented. + +### 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](../../../2-sensors-deployment/asset-tags.md#sample-real-world-tagging) 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. + +### Wiring `mitigated_by` reliably + +- Use the rule's hive key (the value the [Hive API](../../../2-sensors-deployment/asset-tags.md) accepts as `Key`), not its display name. +- Keys are looked up in `dr-general`, `dr-managed`, `dr-service` in that order. A user-defined rule in `dr-general` is the canonical case. +- A rule that is later deleted will not retroactively invalidate the state row, but a future `set_finding_state` to the same key will fail — clean up the old finding first if the rule is being retired. + +### SLA configuration + +Defaults match common compliance baselines (NIST SP 800-40 Rev. 4, FedRAMP Continuous Monitoring): 1 week / 30 days / 90 days / 180 days for critical / high / medium / low. Tighten as needed: + +```json +{ "sla_windows_hours": { "critical": 24, "high": 72 } } +``` -Screenshots are deferred for this revision while the UI continues to land in production. They will be added in a follow-up once the surfaces stabilize. +Partial maps are fine — unset keys keep their default. The `sla_breach_warning` event fires when remaining time falls under 3 days; orgs that want a longer warning horizon should tighten the SLA itself rather than rely on the warning window. + +### "Near-real-time" expectations + +The daily Update tick is per-org, spread across 24h. KEV-match alerts, SLA-breach warnings, 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 four `vuln_finding.*` events to your existing alerting pipeline. A typical wiring: + +- `vuln_finding.kev_match` → Slack `#vuln-priority` + page on-call. +- `vuln_finding.sla_breach_warning` → ticketing system (Jira, Linear). Use `extra.days_to_deadline` to escalate. +- `vuln_finding.state_changed` → audit log SIEM. The full FindingState on the event covers the audit need without a follow-up read. +- `vuln_finding.created` → optional; high-volume on first scan, often filtered to `severity=critical` only. + +## 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](#lc-risk). | +| **MTTR** | Mean Time To Remediation. Computed from `(remediated_at - first_seen_at)` per severity bucket. | +| **SLA** | Service Level Agreement. The per-criticality remediation deadline configured via `sla_windows_hours`. | +| **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](#finding-fingerprint). | +| **Scope** | `org` (per-package, applies to every host) or `host` (per-package-per-sensor). State precedence: host beats org. | +| **Lifecycle** | The state machine `open → in_progress → (mitigated | accepted | false_positive)` plus `clear` resets. See [Lifecycle states](#lifecycle-states). | +| **Lapsed acceptance** | An `accepted` finding whose `expires_at` is in the past. Auto-reverts to `open` at read time with `expired=true`. | +| **Daily Update tick** | Per-org per-day cron firing the four daily scans. Spread across 24h. See [Daily Update tick](#daily-update-tick). | +| **KEV match** | The event fired when a CVE just entered KEV AND the org still has an open finding for it. | +| **SLA breach warning** | The event fired when a finding's age > `sla_window - 3 days` AND state is not in the suppressed set. | ## 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.) and is tracked as a future capability rather than a near-term deliverable. +"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. Customers that need true reachability today should treat the criticality multiplier as the interim signal and watch the changelog for the dedicated capability. +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](../../../2-sensors-deployment/asset-tags.md) — Asset metadata convention consumed by this extension - [Sensor Tags](../../../2-sensors-deployment/sensor-tags.md) — General tagging mechanism, API, and CLI -- [Outputs](../../outputs/index.md) — Routing the Phase 2 events to external systems +- [Outputs](../../outputs/index.md) — Routing the `vuln_finding.*` events to external systems - [Cases](cases.md) — Optional consumer of `vuln_finding.kev_match` for triage - [Using Extensions](../using-extensions.md) — General extension subscription and management From 033dd84209a4f8c98b33f001b333a91fac3ac86e Mon Sep 17 00:00:00 2001 From: Maxime Date: Sun, 10 May 2026 07:38:18 -0700 Subject: [PATCH 4/5] docs(vuln): collapse lifecycle states to 3-resolution model The remediation surface collapsed from a 5-state machine + audit log to a 3-resolution model (mitigated / accepted / false_positive) with no per-finding history. Update the docs to match: rewrite the lifecycle section, replace the set/clear/bulk/list state actions with the new resolution actions, drop the D&R-rule and audit-log workflows + best practices, and note that case_number is reserved for upcoming ext-cases linkage. --- .../limacharlie/vulnerability-reporting.md | 330 ++++++++---------- 1 file changed, 143 insertions(+), 187 deletions(-) diff --git a/docs/5-integrations/extensions/limacharlie/vulnerability-reporting.md b/docs/5-integrations/extensions/limacharlie/vulnerability-reporting.md index f7a517d1..1fffe6b8 100644 --- a/docs/5-integrations/extensions/limacharlie/vulnerability-reporting.md +++ b/docs/5-integrations/extensions/limacharlie/vulnerability-reporting.md @@ -1,6 +1,6 @@ # 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 lifecycle state across rescans, and surfaces the results in the LimaCharlie web app and via the extension API. +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](../../../2-sensors-deployment/asset-tags.md): asset criticality, exposure, environment, owner, and compliance tags are read directly off the sensors and used to prioritize findings, scope filters, and parameterize remediation SLAs. @@ -9,11 +9,11 @@ It is the first consumer of the canonical [`lc:asset:*` tag namespace](../../../ 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](#lc-risk) score that is persisted on every finding row. -4. **Lifecycle.** Every finding has a state (open / in_progress / mitigated / accepted / false_positive) plus an audit log of every transition. State is keyed by a deterministic [fingerprint](#finding-fingerprint) so it survives rescans. +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](#finding-fingerprint) so they survive rescans. 5. **Daily scans.** A per-org daily tick runs four jobs: KEV-match emission, SLA-breach warning, 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](#api-actions)). -The extension is stateless aside from the per-org Spanner-backed tables (`vuln_reports`, `vuln_finding_state`, `vuln_finding_state_history`, `vuln_daily_snapshots`, `vuln_epss_history`, plus rollup tables) and a small `org_value` keyed at `ext_vuln_kev_known_set`. +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 @@ -94,34 +94,31 @@ Tags with malformed values for the closed-set fields (`criticality`, `exposure`, ### Lifecycle states -Every finding has a `state` field that tracks where it is in the remediation pipeline. State is keyed by a [fingerprint](#finding-fingerprint), so it survives rescans and re-resolutions. +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](#finding-fingerprint), so they survive rescans. -| State | Description | Required fields | Counts against SLA | -|-------|-------------|-----------------|--------------------| -| `open` | New finding (implicit when no state row exists). | — | Yes | -| `in_progress` | Remediation is underway. | — | Yes | -| `mitigated` | Compensating control in place; finding is no longer counted as exploitable. Sets `remediated_at` (used by MTTR). | optional `mitigated_by` | No | -| `accepted` | Risk has been formally accepted as an exception. | `reason`, `expires_at` (must be in the future) | No, until `expires_at` lapses | -| `false_positive` | Confirmed not applicable. | `reason` | No | +| Posture | `resolution` | Description | Counts against SLA | +|---------|--------------|-------------|--------------------| +| open | — (no row) | New finding. Implicit; nothing is persisted. | Yes | +| resolved | `mitigated` | Compensating control in place; finding is no longer counted as exploitable. Sets `resolved_at` (used by MTTR). | No | +| resolved | `accepted` | Risk has been formally accepted as an exception, optionally with an `expires_at`. | No, until `expires_at` lapses | +| resolved | `false_positive` | Confirmed not applicable (resolver mis-mapped the package, etc.). | No | -State changes record `event_at`, `state`, optional `reason`, `expires_at`, `assignee`, `mitigated_by`, and `updated_by`. A `clear_finding_state` call appends a row with `state="cleared"` so resets are themselves first-class events. +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. -#### Lapsed accepted (`expired=true`) +`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. -`accepted` is the only state with a required expiry. When `expires_at` is in the past at read time the rendered state flips to `open` and the row is flagged with `expired=true` so the UI can surface the lapsed exception. The Spanner row itself is **not** mutated — auditors can still see when and why the exception was originally granted. +#### Lapsed acceptance -#### `mitigated_by` validation - -When `set_finding_state` is called with `state="mitigated"`, the optional `mitigated_by` field accepts the key of a D&R rule that provides the compensating control. The extension validates the supplied key against `dr-general`, `dr-managed`, and `dr-service` at write time. Unknown keys are rejected; there is no soft-fail mode that records a warning. +`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) -State rows are written at one of two scopes: +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 state row beats an org-scope one for the matching row. Both use the same fingerprint algorithm; the host fingerprint mixes in the `sid`. +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 @@ -213,12 +210,12 @@ CVEs that the org has never had a finding on are not retained — there is no hi ### 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 state overlay (suppressing rows whose effective state is `mitigated` / `accepted` / `false_positive`), and writes one row per `(snapshot_date, severity)` carrying: +`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. Findings whose effective state is one of the suppressed states are excluded by design — the snapshot represents "what the operator still owes," not "every CVE the resolver ever returned." +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 @@ -227,7 +224,7 @@ The platform scheduler (`legion_extension_manager` / `legion_scheduler`'s `ext-u | 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 | `sla_breach` | Emits `vuln_finding.sla_breach_warning` for findings whose age > `sla_window - 3 days` AND state is not in the suppressed set. | +| 2 | `sla_breach` | Emits `vuln_finding.sla_breach_warning` for findings whose age > `sla_window - 3 days` AND that have no `vuln_finding_state` row, or whose row is a lapsed acceptance. | | 3 | `daily_snapshot` | Writes the per-severity open / KEV counts for today (see [Daily snapshots](#daily-snapshots)). | | 4 | `epss_history` | Writes one EPSS row per distinct org CVE for today (see [EPSS history](#epss-history-90-day-series)). | @@ -275,17 +272,15 @@ The full request and response schemas live in the extension's `requestSchema()` | `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_states` | Page through `vuln_finding_state` for the org with optional scope / state / assignee filters. | -| `list_finding_state_history` | Audit log of state mutations for one finding. | +| `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_state` | Move one finding to a new state. | -| `clear_finding_state` | Reset a finding to implicit `open` (deletes the row, audit-logs a `cleared` event). | -| `bulk_set_finding_state` | Apply a state change across up to 100 findings in one call. | +| `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. | ### Internal action @@ -325,7 +320,7 @@ Response: "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 }, - "state": null + "resolution": null } ], "next_cursor": "100", @@ -333,7 +328,7 @@ Response: } ``` -Filters supported: `severity`, `kev_only`, `epss_min`, `state`, `assignee`, `criticality`, `env`, `exposure`. `kev_only` and `epss_min` force-enable `include_enrichment`. +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` @@ -399,7 +394,7 @@ Response (one row per `(package_name, package_version, cve)`): "lc_risk": 87, "kev": { "in_kev": true, "...": "..." }, "epss": { "score": 0.81, "percentile": 0.98 }, - "state": null + "resolution": null } ], "cursor": "", @@ -420,7 +415,7 @@ Request: } ``` -Pass `normalized_package_name` so the state overlay can compute host-scope fingerprints; without it, only org-scope state hits land. Filters supported: `platform`, `platform_string`, `criticality`, `env`, `exposure`, `state`, `assignee`. +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` @@ -485,7 +480,7 @@ Request: } ``` -#### `set_finding_state` +#### `set_finding_resolution` Request: @@ -495,57 +490,48 @@ Request: "cve": "CVE-2024-12345", "normalized_package_name": "openssl", "sid": "550e8400-...", - "state": "accepted", - "reason": "Vendor patch ETA 2026-06-01; mitigated by WAF rule waf-12.", + "resolution": "accepted", "expires_at": "2026-06-15T00:00:00Z", - "assignee": "platform-team" + "case_number": 12345 } ``` -Required fields: `scope` (`org` | `host`), `cve`, `normalized_package_name`, `state`. `sid` is required when `scope=host`. `state=accepted` requires `reason` plus a future `expires_at` (RFC3339). `state=false_positive` requires `reason`. +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: ```json { - "fingerprint": "a3f2…", - "state": { - "scope": "host", + "data": { "fingerprint": "a3f2…", - "state": "accepted", - "reason": "Vendor patch ETA 2026-06-01; mitigated by WAF rule waf-12.", + "scope": "host", + "resolution": "accepted", "expires_at": "2026-06-15T00:00:00Z", - "assignee": "platform-team", - "first_seen_at": "2026-04-30T11:22:00Z", - "remediated_at": null, - "updated_by": "alice@example.com", - "created_at": "2026-05-09T18:30:00Z", - "updated_at": "2026-05-09T18:30:00Z" + "case_number": 12345, + "resolved_at": "2026-05-10T18:30:00Z", + "resolved_by": "alice@example.com", + "updated_at": "2026-05-10T18:30:00Z" } } ``` -`first_seen_at` is seeded from the earliest matching `vuln_reports.created_at` on initial insert and preserved across updates. `remediated_at` is set to wall-clock time when state transitions **into** `mitigated` and cleared when state transitions out, so MTTR can be derived from `(remediated_at - first_seen_at)`. +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. -#### `clear_finding_state` +#### `bulk_set_finding_resolution` -Either supply `fingerprint` directly or `(scope, cve, normalized_package_name [, sid])`: - -```json -{ "scope": "host", "cve": "CVE-2024-12345", "normalized_package_name": "openssl", "sid": "550e8400-..." } -``` - -Returns `{ "cleared": true }` and appends a `state="cleared"` row to the audit log. A clear against an absent row still appends the audit row — explicit clears are a signal worth recording. - -#### `bulk_set_finding_state` - -Apply up to 100 set-state items in one call. Each item is committed independently; partial successes are reported per-item. +Apply up to 100 resolution changes in one call. Each item carries the same fields as `set_finding_resolution`. Items are committed independently; partial successes are reported per-item. ```json { - "items": [ - { "scope": "org", "cve": "CVE-2024-12345", "normalized_package_name": "openssl", "state": "mitigated", "mitigated_by": "dr-rule-waf-12" }, - { "scope": "org", "cve": "CVE-2024-22222", "normalized_package_name": "openssl", "state": "in_progress", "assignee": "platform-team" } + "targets": [ + { "scope": "org", "cve": "CVE-2024-12345", "normalized_package_name": "openssl", "resolution": "mitigated" }, + { "scope": "host", "cve": "CVE-2024-22222", "normalized_package_name": "openssl", "sid": "550e8400-...", "resolution": "false_positive" } ] } ``` @@ -554,59 +540,55 @@ Response: ```json { - "applied": 2, - "results": [ - { "index": 0, "fingerprint": "a3f2…", "state": { "...": "..." } }, - { "index": 1, "fingerprint": "b771…", "state": { "...": "..." } } - ], - "errors": [] + "data": { + "applied": 2, + "results": [ + { "index": 0, "fingerprint": "a3f2…", "resolution": "mitigated", "...": "..." }, + { "index": 1, "fingerprint": "b771…", "resolution": "false_positive", "...": "..." } + ], + "errors": [] + } } ``` Items over 100 reject the entire call with HTTP 400. -#### `list_finding_states` +#### `list_finding_resolutions` -Page through state rows in the org. All filters are optional and stack as AND. +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. ```json -{ "scope": "host", "state": ["accepted", "in_progress"], "assignee": ["platform-team"], "limit": 100, "cursor": "" } -``` - -Response: `{ "results": [, ...], "next_cursor": "..." }`. `limit` defaults to 100; the response is ordered `(scope ASC, fingerprint ASC)`. - -#### `list_finding_state_history` - -Either supply `fingerprint` directly or the canonical inputs: - -```json -{ "scope": "host", "cve": "CVE-2024-12345", "normalized_package_name": "openssl", "sid": "550e8400-...", "limit": 100 } +{ + "scope": "host", + "resolutions": ["accepted", "mitigated"], + "limit": 100, + "cursor": "" +} ``` -Response (ordered `event_at DESC`, default limit 50, max 500): +Response: ```json { - "history": [ - { - "event_at": "2026-05-09T18:30:00Z", - "state": "accepted", - "reason": "Vendor patch ETA 2026-06-01; mitigated by WAF rule waf-12.", - "expires_at": "2026-06-15T00:00:00Z", - "assignee": "platform-team", - "updated_by": "alice@example.com" - }, - { - "event_at": "2026-05-08T14:02:00Z", - "state": "in_progress", - "assignee": "platform-team", - "updated_by": "bob@example.com" - } - ] + "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": "..." + } } ``` -A `clear_finding_state` call appears as a row with `state="cleared"`. `event_at` is the Spanner commit timestamp — the writer never accepts a caller-supplied event time. +`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` @@ -626,8 +608,8 @@ The extension emits the following events through LimaCharlie's standard webhook |-------|-----------|----------------| | `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.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_state` / `bulk_set_finding_state` succeeded. | `fingerprint`, `state` (full FindingState including `first_seen_at` / `remediated_at`) | -| `vuln_finding.sla_breach_warning` | Finding age > `sla_window - 3 days` AND state is not in the suppressed set. Fires once per finding per daily tick. | `cve`, `severity`, `sid`, `hostname`, `first_seen`, `extra.criticality`, `extra.days_to_deadline` | +| `vuln_finding.resolution_changed` | `set_finding_resolution` / `bulk_set_finding_resolution` succeeded (including reopens, where the carried `resolution` is `null`). | `fingerprint`, `scope`, `resolution`, `expires_at`, `case_number`, `resolved_at`, `resolved_by` | +| `vuln_finding.sla_breach_warning` | Finding age > `sla_window - 3 days` AND there is no active resolution row (or the row is a lapsed acceptance). Fires once per finding per daily tick. | `cve`, `severity`, `sid`, `hostname`, `first_seen`, `extra.criticality`, `extra.days_to_deadline` | 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. @@ -649,12 +631,12 @@ slack_channel: "#vuln-priority" 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 `remediated_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`), lifecycle state, and assignee. +- **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, lifecycle state chip, asset-metadata cell. Bulk-select on the CVEs tab opens `SetStateModal` for batch lifecycle 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`, lifecycle history popover, 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 recent state changes. +- **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 @@ -691,109 +673,88 @@ Concrete operator playbooks. Each workflow is a numbered sequence; substitute `< - 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 D&R rule already covers it → **Set state → mitigated**, attach `mitigated_by` (D&R rule key). The audit log records who/when. - - Otherwise → **Set state → in_progress**, attach `assignee`. -5. Optional: open the **Lifecycle history** popover on the CVE/host row to confirm the transition is recorded. + - If a compensating control is in place → **Set resolution → mitigated**. `resolved_at` is stamped and the finding stops counting against SLA. + - 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 `remediated_at - first_seen_at` per severity. It populates only after the first finding is set to `mitigated`. +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. Acceptance / risk acceptance - -When a finding cannot be patched in time: - -1. CVE row → **Set state → accepted**. -2. Fill in `reason` (free-form) and `expires_at` (must be in the future). Optional `assignee` for routing on lapse. -3. The finding stops counting against the SLA until `expires_at`. At that moment it auto-reverts to `open` at read time and the row is surfaced with `expired=true` so the operator knows to revisit. -4. To extend, set state again with a new `expires_at`. To formally close, transition to `mitigated` once the patch lands. +### 4. Risk acceptance -The audit log records every transition including the lapsed-and-renewed case, so compliance auditors can see "accepted by Alice on 2026-03-12 with reason X, expired 2026-04-12, renewed by Bob with reason Y." +When a finding cannot be patched in time and the business formally accepts the risk: -### 5. Mitigated by D&R rule +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 stops counting against the SLA 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. +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). -When exploitation is stopped via a detection rule: +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. -1. Confirm the D&R rule's hive key — typically in `dr-general` or `dr-managed`. -2. Set state: - ```bash - curl -s -X POST "$LC_API/v1/extension/request/ext-vulnerability-reporting" \ - -H "Authorization: Bearer $LC_JWT" \ - -d oid="$OID" -d action="set_finding_state" \ - -d data='{ - "scope": "org", - "cve": "CVE-2024-12345", - "normalized_package_name": "openssl", - "state": "mitigated", - "mitigated_by": "dr-rule-waf-12" - }' - ``` -3. The extension validates the rule key against `dr-general`, `dr-managed`, and `dr-service`; an unknown key is rejected with a 400. -4. `remediated_at` is set on the row; MTTR begins counting toward the eventual stat. +```bash +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" + }' +``` -### 6. Compliance evidence export +### 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. State evidence is included per finding: `accepted` rows surface as "Risk accepted" with the rationale and expiry; `mitigated` rows surface with the `mitigated_by` rule key. +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. -### 7. Host-centric review +### 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 state → `mitigated` (host scope) for findings the host has individually patched. - -### 8. Audit-log review - -1. On any finding's CVE detail page, click the **Lifecycle history** popover. -2. Or via API: - ```bash - curl -s -X POST "$LC_API/v1/extension/request/ext-vulnerability-reporting" \ - -H "Authorization: Bearer $LC_JWT" \ - -d oid="$OID" -d action="list_finding_state_history" \ - -d data='{ - "scope": "host", - "cve": "CVE-2024-12345", - "normalized_package_name": "openssl", - "sid": "550e8400-...", - "limit": 100 - }' - ``` -3. Rows are returned `event_at DESC`. Every transition is captured including `cleared` resets. +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. -### 9. EPSS trend monitoring +### 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 whose state is still `open`. +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): ```json { "cve": "CVE-2024-12345", "days": 365 } ``` -### 10. Bulk operations +### 8. Bulk operations 1. CVEs tab → multi-select rows. -2. Bulk action → **Set state** opens `SetStateModal` with the selected fingerprints as targets. Up to 100 per call. +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[]` (with `first_seen_at` and `remediated_at`) directly — no follow-up `list_finding_states` round trip is needed. +4. The frontend uses the response's `results[]` directly — no follow-up `list_finding_resolutions` round trip is needed. ## Best practices -### Choosing a lifecycle state +### 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 by the SLA window, business-accepted exception | `accepted` (optional `expires_at`) | +| Resolver false positive (wrong product / wrong version) | `false_positive` | -| Situation | State | -|-----------|-------| -| Detected, no work yet started | (leave implicit `open`) | -| Ticket assigned, patching in flight | `in_progress` (with `assignee`) | -| D&R rule blocks exploitation; patch coming later | `mitigated` (with `mitigated_by`) | -| Patch lands, rescan confirms gone | (leave to natural attrition; the rescan removes the row) | -| Cannot patch by the SLA window, business-accepted exception | `accepted` (with `reason` + future `expires_at`) | -| Resolver false positive (wrong product / wrong version) | `false_positive` (with `reason`) | +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. -Do not use `mitigated` for "patch in progress" — `remediated_at` is set on entry to `mitigated`, which would skew MTTR. Use `in_progress` 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 @@ -810,12 +771,6 @@ Drive these via `limacharlie tag mass-add` keyed off existing infrastructure tag 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. -### Wiring `mitigated_by` reliably - -- Use the rule's hive key (the value the [Hive API](../../../2-sensors-deployment/asset-tags.md) accepts as `Key`), not its display name. -- Keys are looked up in `dr-general`, `dr-managed`, `dr-service` in that order. A user-defined rule in `dr-general` is the canonical case. -- A rule that is later deleted will not retroactively invalidate the state row, but a future `set_finding_state` to the same key will fail — clean up the old finding first if the rule is being retired. - ### SLA configuration Defaults match common compliance baselines (NIST SP 800-40 Rev. 4, FedRAMP Continuous Monitoring): 1 week / 30 days / 90 days / 180 days for critical / high / medium / low. Tighten as needed: @@ -836,7 +791,7 @@ Route the four `vuln_finding.*` events to your existing alerting pipeline. A typ - `vuln_finding.kev_match` → Slack `#vuln-priority` + page on-call. - `vuln_finding.sla_breach_warning` → ticketing system (Jira, Linear). Use `extra.days_to_deadline` to escalate. -- `vuln_finding.state_changed` → audit log SIEM. The full FindingState on the event covers the audit need without a follow-up read. +- `vuln_finding.resolution_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.created` → optional; high-volume on first scan, often filtered to `severity=critical` only. ## Glossary @@ -850,18 +805,19 @@ Route the four `vuln_finding.*` events to your existing alerting pipeline. A typ | **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](#lc-risk). | -| **MTTR** | Mean Time To Remediation. Computed from `(remediated_at - first_seen_at)` per severity bucket. | +| **MTTR** | Mean Time To Remediation. Computed from `(resolved_at - first_seen_at)` per severity bucket. | | **SLA** | Service Level Agreement. The per-criticality remediation deadline configured via `sla_windows_hours`. | | **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](#finding-fingerprint). | -| **Scope** | `org` (per-package, applies to every host) or `host` (per-package-per-sensor). State precedence: host beats org. | -| **Lifecycle** | The state machine `open → in_progress → (mitigated | accepted | false_positive)` plus `clear` resets. See [Lifecycle states](#lifecycle-states). | -| **Lapsed acceptance** | An `accepted` finding whose `expires_at` is in the past. Auto-reverts to `open` at read time with `expired=true`. | +| **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](#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 four daily scans. Spread across 24h. See [Daily Update tick](#daily-update-tick). | | **KEV match** | The event fired when a CVE just entered KEV AND the org still has an open finding for it. | -| **SLA breach warning** | The event fired when a finding's age > `sla_window - 3 days` AND state is not in the suppressed set. | +| **SLA breach warning** | The event fired when a finding's age > `sla_window - 3 days` AND there is no active resolution row (or the row is a lapsed acceptance). | +| **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) From f2d02ae5b257c816d54bfd348190f333e3bd9d56 Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Tue, 12 May 2026 20:12:53 -0700 Subject: [PATCH 5/5] docs(vuln): align doc with shipped code (#224) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes claims that don't match the extension as it ships: - `sla_windows_hours` config field (doesn't exist in Configuration struct) - `vuln_finding.sla_breach_warning` event (never emitted; no SLA-breach scan in the daily tick) - `vuln_finding.resolution_changed` event (real name is `vuln_finding.state_changed`) - Lifecycle "Counts against SLA" column (no SLA enforcement in code) - Daily tick described as four scans (it's three) with 60s timeout (it's 10 minutes — `scanLongRunningTimeout`) Adds documentation for capabilities the doc had previously omitted: - `vuln_finding.closed` event (fired by org-scope fingerprint reconciliation and by `reset_asset_findings`) - `reset_asset_findings` action (used for reformat / reimage / decommission of a host) Replaces the SLA-configuration best-practices section with guidance on how to track remediation cadence externally given that the extension does not enforce SLAs natively. Co-authored-by: Claude Opus 4.7 (1M context) --- .../limacharlie/vulnerability-reporting.md | 87 ++++++++++--------- 1 file changed, 47 insertions(+), 40 deletions(-) diff --git a/docs/5-integrations/extensions/limacharlie/vulnerability-reporting.md b/docs/5-integrations/extensions/limacharlie/vulnerability-reporting.md index 1fffe6b8..cc975698 100644 --- a/docs/5-integrations/extensions/limacharlie/vulnerability-reporting.md +++ b/docs/5-integrations/extensions/limacharlie/vulnerability-reporting.md @@ -2,7 +2,7 @@ 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](../../../2-sensors-deployment/asset-tags.md): asset criticality, exposure, environment, owner, and compliance tags are read directly off the sensors and used to prioritize findings, scope filters, and parameterize remediation SLAs. +It is the first consumer of the canonical [`lc:asset:*` tag namespace](../../../2-sensors-deployment/asset-tags.md): 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 @@ -10,7 +10,7 @@ It is the first consumer of the canonical [`lc:asset:*` tag namespace](../../../ 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](#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](#finding-fingerprint) so they survive rescans. -5. **Daily scans.** A per-org daily tick runs four jobs: KEV-match emission, SLA-breach warning, open-finding snapshot for the burndown tile, and EPSS-percentile snapshot for the per-CVE history sparkline. +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](#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`. @@ -39,7 +39,6 @@ The configuration is edited on the extension page in the LimaCharlie web app. Al | Field | Type | Default | Description | |-------|------|---------|-------------| | `scan_mode` | enum | `scheduled` | One of `scheduled`, `manual`, `all`. See [Setup](#setup). | -| `sla_windows_hours` | object | `{"critical":168,"high":720,"medium":2160,"low":4320}` | Per-criticality remediation deadlines in hours. Keys must be one of `critical` / `high` / `medium` / `low`; values must be positive integers. Unset keys fall back to the defaults (1 week / 30 days / 90 days / 180 days, matching common NIST and FedRAMP baselines). | | `criticality_tag_overrides` | object | `{}` | Map of `{your-tag → canonical-bucket}` for organizations that already run their own asset-tag taxonomy. See [Asset Metadata](#asset-metadata). | ### Example @@ -47,12 +46,6 @@ The configuration is edited on the extension page in the LimaCharlie web app. Al ```json { "scan_mode": "scheduled", - "sla_windows_hours": { - "critical": 24, - "high": 168, - "medium": 720, - "low": 2160 - }, "criticality_tag_overrides": { "crown-jewel": "critical", "tier-1": "high", @@ -61,13 +54,13 @@ The configuration is edited on the extension page in the LimaCharlie web app. Al } ``` -`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. `sla_windows_hours` keys must be canonical buckets and values must be positive integers; partial maps are valid (unset keys fall back to defaults). +`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](../../../2-sensors-deployment/asset-tags.md) and uses them to: -- **Prioritize findings.** `lc:asset:criticality:*` is the multiplier in the LC Risk score and the source for the per-criticality SLA window. +- **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. @@ -96,12 +89,12 @@ Tags with malformed values for the closed-set fields (`criticality`, `exposure`, 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](#finding-fingerprint), so they survive rescans. -| Posture | `resolution` | Description | Counts against SLA | -|---------|--------------|-------------|--------------------| -| open | — (no row) | New finding. Implicit; nothing is persisted. | Yes | -| resolved | `mitigated` | Compensating control in place; finding is no longer counted as exploitable. Sets `resolved_at` (used by MTTR). | No | -| resolved | `accepted` | Risk has been formally accepted as an exception, optionally with an `expires_at`. | No, until `expires_at` lapses | -| resolved | `false_positive` | Confirmed not applicable (resolver mis-mapped the package, etc.). | No | +| 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. @@ -219,14 +212,13 @@ A bucket with zero findings is still written on a quiet day so the burndown spar ### 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 four scans sequentially with an independent 60-second timeout per scan; one scan's failure does not suppress the others. +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 | `sla_breach` | Emits `vuln_finding.sla_breach_warning` for findings whose age > `sla_window - 3 days` AND that have no `vuln_finding_state` row, or whose row is a lapsed acceptance. | -| 3 | `daily_snapshot` | Writes the per-severity open / KEV counts for today (see [Daily snapshots](#daily-snapshots)). | -| 4 | `epss_history` | Writes one EPSS row per distinct org CVE for today (see [EPSS history](#epss-history-90-day-series)). | +| 2 | `daily_snapshot` | Writes the per-severity open / KEV counts for today (see [Daily snapshots](#daily-snapshots)). | +| 3 | `epss_history` | Writes one EPSS row per distinct org CVE for today (see [EPSS history](#epss-history-90-day-series)). | 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. @@ -281,6 +273,7 @@ The full request and response schemas live in the extension's `requestSchema()` | `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 @@ -600,6 +593,22 @@ 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. + +```json +{ "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): + +```json +{ "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. @@ -607,9 +616,9 @@ The extension emits the following events through LimaCharlie's standard webhook | 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.resolution_changed` | `set_finding_resolution` / `bulk_set_finding_resolution` succeeded (including reopens, where the carried `resolution` is `null`). | `fingerprint`, `scope`, `resolution`, `expires_at`, `case_number`, `resolved_at`, `resolved_by` | -| `vuln_finding.sla_breach_warning` | Finding age > `sla_window - 3 days` AND there is no active resolution row (or the row is a lapsed acceptance). Fires once per finding per daily tick. | `cve`, `severity`, `sid`, `hostname`, `first_seen`, `extra.criticality`, `extra.days_to_deadline` | +| `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. @@ -673,7 +682,7 @@ Concrete operator playbooks. Each workflow is a numbered sequence; substitute `< - 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 stops counting against SLA. + - 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`. @@ -689,7 +698,7 @@ When a finding cannot be patched in time and the business formally accepts the r 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 stops counting against the SLA 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. +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). @@ -747,7 +756,7 @@ curl -s -X POST "$LC_API/v1/extension/request/ext-vulnerability-reporting" \ | 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 by the SLA window, business-accepted exception | `accepted` (optional `expires_at`) | +| 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. @@ -771,28 +780,28 @@ Drive these via `limacharlie tag mass-add` keyed off existing infrastructure tag 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. -### SLA configuration +### Tracking remediation deadlines -Defaults match common compliance baselines (NIST SP 800-40 Rev. 4, FedRAMP Continuous Monitoring): 1 week / 30 days / 90 days / 180 days for critical / high / medium / low. Tighten as needed: +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: -```json -{ "sla_windows_hours": { "critical": 24, "high": 72 } } -``` +- `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. -Partial maps are fine — unset keys keep their default. The `sla_breach_warning` event fires when remaining time falls under 3 days; orgs that want a longer warning horizon should tighten the SLA itself rather than rely on the warning window. +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, SLA-breach warnings, 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. +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 four `vuln_finding.*` events to your existing alerting pipeline. A typical wiring: +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.sla_breach_warning` → ticketing system (Jira, Linear). Use `extra.days_to_deadline` to escalate. -- `vuln_finding.resolution_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.created` → optional; high-volume on first scan, often filtered to `severity=critical` only. +- `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 @@ -806,7 +815,6 @@ Route the four `vuln_finding.*` events to your existing alerting pipeline. A typ | **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](#lc-risk). | | **MTTR** | Mean Time To Remediation. Computed from `(resolved_at - first_seen_at)` per severity bucket. | -| **SLA** | Service Level Agreement. The per-criticality remediation deadline configured via `sla_windows_hours`. | | **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:*`. | @@ -814,9 +822,8 @@ Route the four `vuln_finding.*` events to your existing alerting pipeline. A typ | **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](#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 four daily scans. Spread across 24h. See [Daily Update tick](#daily-update-tick). | +| **Daily Update tick** | Per-org per-day cron firing the three daily scans. Spread across 24h. See [Daily Update tick](#daily-update-tick). | | **KEV match** | The event fired when a CVE just entered KEV AND the org still has an open finding for it. | -| **SLA breach warning** | The event fired when a finding's age > `sla_window - 3 days` AND there is no active resolution row (or the row is a lapsed acceptance). | | **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)