OSSEC / Loki / Alloy

Shipping OSSEC alerts to Loki with Grafana Alloy

OSSEC has been able to write its alerts as JSON since version 2.9. That one line of configuration is enough to hand them to Grafana Alloy, which can label, timestamp, and metric them on the way into Loki.

OSSEC alerts in Grafana, with labels applied by the Alloy pipeline visible on each log line
OSSEC alerts in Grafana, with the labels extracted by Alloy on each line.

Getting OSSEC logs into Loki? How?

Let's say you run OSSEC as a host IDS - file integrity checking, rootkit detection, log-based rules, active responses - and you already operate a Grafana Loki stack.

The alerts live on disk in /var/ossec/logs/alerts/, rotating and compressing daily.

What you want is for those same alerts to appear in Loki, queryable alongside your application and system logs, labelled by the things you actually want to filter by, such as the rule level, the rule group, the host that produced the alert, or the source IP.

Grafana Alloy is what ships logs to Loki. Already popular for syslogs and other types of log format, it can tails the JSON file that OSSEC emits, parse each line, promote chosen fields to labels, and send them on to Loki.

All it takes on OSSEC's side is a one-line change in ossec.conf to create on the JSON output that Alloy reads from.

Why ship OSSEC alerts to Loki?

OSSEC's local alerts.log and alerts.json are rotated and checksummed daily, and they live on the OSSEC manager itself. That's fine for forensics on that host, but it's an awkward place to look when you're correlating a security event with what the application, the firewall, and the web server were doing at the same time.

Loki gives those alerts a single query plane with everything else. Once the alerts are labelled and in Loki, a LogQL query like {system="ossec", level=~"0[7-9]|1[0-5]"} shows every alert at level 7 and above across every host you ship from.

The same query can feed a Grafana dashboard panel, a Grafana alerting rule, or a one-off investigation in Grafana's Explore pane.

Retention is cheap, because Loki is really index-light (and aside from speed, is another reason I prefer it over Elasticsearch): it indexes the labels, keeps the log lines compressed, and leaves the full alert text for you to expand when you need it.

There's also a free metric that comes out of this pipeline. Alloy can count alerts as they pass through and export a counter on its own /metrics endpoint, so you get an ossec_alerts_total series to alert on - "alert rate above threshold for this host" - without standing up a separate monitoring path for security events.

Native OSSEC alongside Loki

OSSEC is a lightweight, single-process daemon. Its rule set, decoders, file integrity engine, and active responses are enough on their own for a host that needs local detection and response. For a site already standardised on the Grafana stack - Loki for logs, perhaps Prometheus for metrics, Grafana for dashboards and alerting - pointing OSSEC's JSON output into Loki keeps security events in the same observability plane as everything else. One query language, one dashboarding tool, one alerting layer. The OSSEC manager keeps doing detection; Loki keeps the history; Grafana keeps your eyes on it all.

Turning on JSON output in OSSEC

OSSEC has been able to write JSON-formatted alerts since version 2.9. The output lands at /var/ossec/logs/alerts/alerts.json, side by side with the legacy alerts.log. The legacy file is unaffected, and the daily rotation and checksumming applies to both.

The switch is a single element in the <global> block of /var/ossec/etc/ossec.conf:

<ossec_config>
  <global>
    <jsonout_output>yes</jsonout_output>
    <!-- ...your other global settings... -->
  </global>
</ossec_config>

Restart OSSEC and confirm the file is being written to:

sudo systemctl restart ossec
tail -n 1 /var/ossec/logs/alerts/alerts.json | jq .

Each line is one JSON object. The classic OSSEC format - the one this pipeline is written against - looks like:

{"rule":{"level":5,"comment":"Syslogd restarted.","sidid":1005,"group":"syslog,errors,"},"id":"1510376401.0","TimeStamp":1510376401000,"location":"/var/log/messages","full_log":"Nov 11 00:00:01 ix syslogd[72090]: restart","hostname":"ix","program_name":"syslogd"}

The fields this pipeline reads are:

  • TimeStamp - when OSSEC processed the event.
  • rule.level - the rule's severity, 0 to 15.
  • rule.group - the comma-separated rule groups (singular group, a string, in this format).
  • hostname - the agent or manager name. This can be a bare hostname, or a parenthesised form like (junction) 192.168.17.17->/var/log/maillog when OSSEC records the agent and the log path together. The pipeline handles both.
  • srcip - the source IP, when the rule extracted one.

The Alloy pipeline

The config below tails alerts.json, runs it through a processing pipeline, and writes the result to Loki. I'll walk through what each stage does and why.

loki.write "loki" {
  endpoint {
    url = "https://loki.example.net/loki/api/v1/push"

    basic_auth {
      username = "someuser"
      password = "some-pass"
    }
  }
  external_labels = {}
}

loki.source.file "ossec_alerts" {
  targets = [
    {
      __path__    = "/var/ossec/logs/alerts/alerts.json",
      __address__ = "localhost",
      system      = "ossec",
    },
  ]

  forward_to = [loki.process.ossec_alerts.receiver]
}

loki.process "ossec_alerts" {
  stage.json {
    expressions = {
      timestamp   = "TimeStamp",
      level       = "rule.level",
      ossec_group = "rule.group",
      ossec_host  = "hostname",
      ip          = "srcip",
    }
  }

  // This strips parentheses from ossec_host by overwriting it with the capture group.
  stage.regex {
    source     = "ossec_host"
    expression = "^\\((?P<ossec_host>\\S+)\\)"
  }

  // Pad level so sorting works as intended
  stage.template {
    source   = "level"
    template = "{{ printf \"%02s\" .Value }}"
  }

  // Promote extracted fields to labels
  stage.labels {
    values = {
      level       = "",
      ossec_group = "",
      ossec_host  = "",
      ip          = "",
    }
  }

  // Use the parsed timestamp as the log entry timestamp
  stage.timestamp {
    source = "timestamp"
    format = "RFC3339"
  }

  // Export a counter metric (exposed on Alloy's /metrics endpoint)
  stage.metrics {
    metric.counter {
      name        = "ossec_alerts_total"
      description = "count of alerts"
      action      = "inc"
      source      = "level"
      prefix      = "" // default prefix would otherwise be added
    }
  }

  // send processed logs onward
  forward_to = [loki.write.loki.receiver]
}

Reading the file

loki.source.file tails /var/ossec/logs/alerts/alerts.json. The system = "ossec" key on the target becomes a label on every line that comes through it, so even before the processing pipeline runs, the log stream already carries system="ossec". If you tail other files through their own loki.source.file blocks, the system label is what keeps them apart in Loki.

Parsing the JSON line

stage.json extracts five fields from each line into the pipeline's extracted map: TimeStamp becomes timestamp, rule.level becomes level, rule.group becomes ossec_group, hostname becomes ossec_host, and srcip becomes ip. At this point they're in the pipeline, but they're not labels yet.

Normalising the hostname

The hostname field isn't always a clean hostname. When OSSEC records the agent alongside the log path, it looks like (junction) 192.168.17.17->/var/log/maillog. As a label value that's noisy and high-cardinality, which reduces the performance benefits you otherwise aim for in Loki: the IP and path make every value nearly unique. The stage.regex strips it back to just the agent name: the expression ^\((?P<ossec_host>\S+)\) matches a leading parenthesised token and, by naming the capture group ossec_host, overwrites the extracted value with just junction. Lines whose hostname is already a bare hostname are left untouched, because the regex simply doesn't match, and the value passes through.

Padding the rule level

OSSEC rule levels run from 0 to 15. As a label value they're strings, so lexicographic sorting puts 11 before 3 - which makes a mess of dropdowns and ordered queries. The stage.template pads the level to two characters with {{ printf "%02s" .Value }}, so 3 becomes 03 and sorting works as you'd expect. The padding happens before the stage.labels promotes the value to a label, so Loki only ever sees the padded form.

Promoting to labels

stage.labels takes four of the extracted values - level, ossec_group, ossec_host, and ip - and turns them into Loki labels. From this point on, every alert line in Loki is labelled with its rule level, its rule group, its host, and (when the rule extracted one) its source IP. Combined with the system="ossec" label from the source block, that's the shape of the log stream in Loki.

Setting the entry timestamp

stage.timestamp uses the parsed TimeStamp as the log entry's time in Loki, so the alert appears at the moment OSSEC processed it - rather than at the moment Alloy ingested it. The format here is RFC3339. The TimeStamp field's format varies across OSSEC versions: some emit a Unix epoch integer (for which UnixMs is the right format), others an ISO 8601 string. If the stage can't parse the value, Loki falls back to ingestion time and the lines still arrive - so it's worth confirming in Grafana that the entry timestamps line up with the alert times in alerts.json rather than the ingestion time. A quick tail of alerts.json next to the same moment in Grafana's Explore view will tell you.

Exporting an alert counter

stage.metrics increments a counter named ossec_alerts_total for every alert that passes through, labelled by level. The counter is exposed on Alloy's own /metrics endpoint, so if Alloy is already scraped by Mimir or Prometheus, you have an alert-rate series for free. The prefix = "" stops Alloy from prepending its default metric prefix, so the series is ossec_alerts_total{level="07", ...} and not something longer. A Grafana alerting rule on rate(ossec_alerts_total[5m]) is the natural "alert burst" detector.

Writing to Loki

loki.write is the sink. The external_labels block is empty here - every label on the stream comes from the source and the pipeline. If you ship from more than one OSSEC manager, this is the place to add a cluster or site external label so you can tell the managers apart in Loki, since the hostname label carries the agent, not the manager.

Verifying it in Grafana

Once Alloy is running and OSSEC is writing JSON, open Grafana's Explore, pick the Loki datasource, and start with the stream:

{system="ossec"}

You should see alert lines flowing in. From there, the labels let you narrow down:

{system="ossec", level=~"0[7-9]|1[0-5]"}
{system="ossec", ossec_host="web-01", ossec_group=~".*sshd.*"}
{system="ossec", ip="203.0.113.42"}

The first shows every alert at level 7 and above. The second shows SSH-related alerts from a specific host. The third shows every alert attributed to a given source IP, across all hosts and all rule groups - which is often the fastest way to follow one actor across a fleet.

If you've added the counter metric and you're scraping it, a panel with sum by (ossec_host) (rate(ossec_alerts_total[5m])) shows the alert rate per host, and a Grafana alerting rule on the same query is how you get paged on a burst.

A note on the file and its rotation

alerts.json is rotated and compressed daily by OSSEC, alongside alerts.log. Alloy tails the live file and handles truncation and rotation on its own, so there's nothing to configure for that. The compressed archives (.gz) are left alone, which is the right behaviour: you want Alloy reading the live file, and the archives are there for forensic replay if you ever need to re-ingest a window.

If you ever do need to backfill - say, after a Loki outage - point a temporary loki.source.file block at the decompressed archive, or use alloy's --position file handling to re-read from the start of the current file. In practice, the daily rotation means the live file never grows unbounded, and the pipeline stays cheap to run.

At a glance
  • Source: /var/ossec/logs/alerts/alerts.json (enabled via <jsonout_output>yes</jsonout_output>)
  • Labels: system, level (zero-padded), ossec_group, ossec_host, ip
  • Hostname: parenthesised form stripped to agent name via regex
  • Level: padded to two chars for correct lexicographic sort
  • Timestamp: from TimeStamp field (check your OSSEC version's format)
  • Metric: ossec_alerts_total counter on Alloy's /metrics
  • Sink: loki.write to your Loki push endpoint
LogQL starting points
  • {system="ossec"} - the full stream
  • {system="ossec", level=~"0[7-9]|1[0-5]"} - level 7 and above
  • {system="ossec", ossec_host="web-01"} - one host
  • {system="ossec", ip="203.0.113.42"} - one source IP across all hosts
  • sum by (ossec_host) (rate(ossec_alerts_total[5m])) - alert rate per host
Need help with OSSEC or a Loki stack?
I do contract sysadmin and security infrastructure work: host IDS, log aggregation, and the dashboards and alerting on top.
Did you appreciate this article? Any support is appreciated!