Skip to content

Security: angeldeleon/logcoverage

SECURITY.md

Security Policy

Reporting a vulnerability

Report privately through GitHub's private vulnerability reporting rather than opening a public issue.

Include affected version, reproduction steps, and impact. I aim to acknowledge within 72 hours and to ship a fix or mitigation plan within 30 days for confirmed issues. Credit in the advisory unless you would rather not be named.

Threat model

logcoverage runs on a management host holding a credential that can search a SIEM — which, on most estates, means most of the organisation's data. It reads a directory tree of rule files it did not write, usually a clone of a public repository with a fork's worth of local additions. And it writes a file that is a map of exactly which detections are blind.

That last part is worth being blunt about, because it drives most of the handling advice below.

The state file and the report are an attack plan. Not a summary, not a status page — a list of which ATT&CK techniques have no working detection, which log sources stopped arriving and how long ago, and which critical systems were never onboarded at all. Read together, that is a description of what somebody could do on your network without anything firing, and roughly how much time they have got before somebody notices the pipeline. It is the most valuable file on the host this runs on, and it is more useful to an attacker than most credentials.

Threat Mitigation
Theft of the coverage map The state file is created 0600, opened through a descriptor with O_NOFOLLOW and O_NONBLOCK, permission-repaired through that descriptor rather than by path, namespaced per estate, and gitignored. Reports are written 0600 too, not at the process umask. Neither is encrypted at rest — that is the host's job, and the host should be treated accordingly
Writing to the SIEM No source has a write path. Every request is a GET or a POST that carries a search; there is no code path that creates, edits, acknowledges or deletes anything. A read-only token is sufficient, and the tool is designed so that anything more is a signal something is wrong
Arbitrary query via config No config key accepts a search, a query fragment or an API path. The searches each source runs are string constants in the source modules and reviewed in-tree. The only operator-supplied value that reaches a search is index_filter, held to [A-Za-z0-9_*-] — a character set that cannot terminate a term, open a subsearch, or add a pipe, and which narrows both the inventory search and the field sample. A config-injectable search would turn a coverage report into an arbitrary-query primitive holding a token that can read every index
Reading more of the SIEM than the job needs The inventory searches are metadata searches: tstats over index-time fields in Splunk, a summarize over table metadata in Sentinel. Neither reads event content. The exception is sample_fields, which is off by default in both sources: there is no metadata answer to "which fields does this sourcetype carry", so Splunk runs a bounded event search — capped at 500 events per sourcetype and 200 sourcetypes — that returns field names only. Nothing in either module can return a value out of an event, but with sample_fields on, a metadata-scoped token is no longer sufficient
A hostile rule directory Bounded traversal: file count, total bytes, per-file bytes and directory depth are all capped, and hitting any cap marks the rule set incomplete rather than short. Symlinks are refused outright and named in the report — not "symlinks that escape the root", which is a race between the check and the open, but all of them. Every open uses O_NOFOLLOW and O_NONBLOCK with a regular-file check on the descriptor afterwards, so a FIFO in the rule directory fails instead of hanging the run forever
Code execution via a rule file or config yaml.safe_load and safe_load_all only, never load. Size-capped, document-count-capped. Config rejects unknown keys; Sigma files ignore them, deliberately, because the spec gains optional keys between releases and a parser that refused them would refuse most real rule sets
A malformed rule silently reducing coverage A rule this parser cannot understand becomes an indeterminate rule: it contributes to no coverage claim in either direction and produces an operational finding naming the file. Dropping it silently would leave the techniques it covered looking either fully covered or entirely uncovered, and both would be confident claims made from a file that was never read
Report and alert injection — a crafted rule title forging a finding line, a phishing link, or a tracking pixel in the report an analyst opens Rule titles, source names and field names come from files this tool did not write. They are sanitised at ingestion (control characters collapsed, invisible and bidi-override characters dropped, unpaired surrogates replaced, length capped) and escaped again at every renderer, for that renderer's syntax. Markdown link, image and code-span metacharacters are escaped — including the backslash, which escapes the renderer's own markup rather than the value's, so a title ending in one cannot leave the emphasis around a finding open — and a bare URL — which needs none of them, and which most renderers autolink on sight — is quarantined inside a code span, where autolinking does not happen; HTML output is fully entity-escaped; email headers are validated and control characters stripped. Slack is handled structurally rather than by escaping — mrkdwn has no backslash escape, so finding prose is emitted as plain_text with emoji: false, where no markup is interpreted at all
Name spoofing — a source or rule whose displayed name is not the name it matches on Bidi overrides and zero-width characters are stripped, so a name cannot render as one thing and be stored as another. Source matching is NFKC-normalised, CamelCase-split and Unicode-aware, so a fullwidth or non-Latin spelling neither evades the catalog nor disappears from the report. A name that would normalise to the empty string keeps its sanitised text instead, because an empty key is skipped everywhere and would buy total invisibility
SSRF to cloud metadata or internal services Every outbound URL is validated and its host resolved before connecting; loopback, RFC1918, link-local (169.254.169.254), CGNAT, reserved, multicast and IPv4-mapped/6to4/NAT64 spellings are refused. allow_internal exists because a search head on a private address is the normal deployment. It is a per-source config field, never applies to a notification destination, and never survives a cross-origin redirect. It defaults to false for Sentinel, whose workspace endpoint is a public Azure address; it defaults to true for Splunk, where an internal search head is the ordinary case and defaulting the other way would fail every real install on its first run. Set allow_internal: false on a Splunk search head that is genuinely public
SSRF via redirect Redirects are followed manually, capped at 3 hops, re-validated at each. allow_internal does not survive a cross-origin hop, so a compromised or on-path SIEM cannot redirect the scanner into the rest of the network
Credential forwarding via redirect Credential-bearing headers and the request body are dropped on any cross-origin hop; 301/302/303 downgrade to GET. This covers the form-encoded Splunk search and the OAuth2 token request, whose body carries a client secret
Secret leakage into a finding, a report or a webhook Redaction happens at the construction boundary, not at the output sinks: Finding.__post_init__ scrubs secret-shaped keys from evidence. There are four sinks today and the fifth will not remember to redact, so a finding simply never holds a secret. Provider error bodies are never logged, because a Splunk error payload can echo the request and the request carried the token
Secret leakage into logs Secrets come from the environment, never config. A *_env field containing a URL or path is rejected. A logging filter redacts credential-shaped strings — including JWTs, Splunk Splunk <key> headers and bearer tokens — in messages and in tracebacks; URLs in error messages are truncated to scheme and host
Prompt injection via a rule title The model runs after every finding, severity, technique verdict and exit code is final and cannot change them. Its output is prose that no code parses. Input is fenced and framed as untrusted. The summary is rendered with line-leading markup escaped, so a coerced reply cannot open a heading, list or table and pass itself off as the coverage table
Resource exhaustion from a hostile input Rule tree caps (files, bytes, depth), per-rule source/field/technique caps, per-source field caps, per-analysis finding caps, streamed HTTP with an incremental size cap, config and state size caps, bounded evidence structures, bounded serialised output in every renderer and every notification destination
False findings from partial data The tri-state runs through the whole tool: an unreadable facet produces no coverage finding, produces an operational finding saying so, and is merged over the baseline rather than replacing it. A truncated source listing marks its facet indeterminate rather than short. A baseline sharing no observations with the collection is reported instead of silently producing nothing. A technique with one live detection and one whose source could not be queried stays live — calling it degraded would make a refused search read as coverage lost since the last run, at high. A source the inventory listed but could not confirm produces the indeterminate finding rather than the silence or collapse one, so the per-source findings cannot contradict the technique table above them. Every renderer, including HTML, carries the coverage notes
State corruption Written atomically: temp file in the same directory, fchmod 0600, fsync, then os.replace. A corrupt file degrades to "no baseline" rather than stopping the job; a symlink or FIFO raises

The failure mode this tool guards against hardest

Reporting a source it could not query as a source the SIEM does not have.

It is not a memory-safety bug and it will not appear in a scanner, but it is the most damaging thing this tool could do. A detection engineer reads "42 detections read sources your SIEM does not ingest", spends a morning proving the pipelines are fine, and never opens the second report — including the one, three weeks later, that is correct.

So: a source is reported as absent only when the inventory is known to be complete. Every other case produces an operational finding, capped at low, that says what could not be established. There is a CI invariant asserting no module maps the indeterminate state onto absence, and a parametrised regression test asserting that every analysis, given a collection that established nothing, produces nothing but operational findings.

Explicitly out of scope

  • The integrity of the SIEM's own answers. logcoverage trusts what the search head returns. An attacker who can alter those answers can make a dead source look alive, and nothing here changes that. This is a coverage analyser, not a tamper-evident audit log.
  • Per-host visibility. The SIEM reports that a source is arriving, not from how many hosts. One machine that stopped forwarding is invisible while its peers keep the source alive. The critical-systems inventory catches "nobody onboarded this system"; nothing here catches "one of forty stopped".
  • Time-of-check/time-of-use on the HTTP client. DNS can change between validation and connection. The guard raises the bar substantially against accidental and opportunistic SSRF without claiming to be airtight.
  • Remediation. The tool detects and reports. It has no capability to enable a rule, restart a forwarder or onboard a source, and adding one would require write privilege on the SIEM and turn it into an attack path.
  • Whether a detection is any good. A live technique means something would fire, not that it would fire on the right thing. Rule quality is a different problem.

Supply chain

  • Two runtime dependencies: PyYAML and httpx. Every dependency is attack surface someone else maintains.
  • CI runs pip-audit on every push and weekly.
  • Workflow permissions default to contents: read; jobs opt in to more.
  • Dependabot is enabled for pip and GitHub Actions.
  • Actions are pinned to full commit SHAs, with the version tag kept in a trailing comment so Dependabot can still propose updates. A tag is mutable and can be repointed at malicious code by anyone who compromises the action's repository; a SHA cannot.

Running it safely

  • Treat state.json as sensitive, and treat it as more sensitive than the SIEM token. It is a map of which detections are blind and how long they have been that way — an attacker holding it knows what they can do unobserved. It is created 0600 and gitignored; keep it that way, and if you commit it for baseline persistence, use a private repository.
  • The same applies to reports. The JSON rendering carries every finding and its evidence, and the technique table is the same map in a friendlier format. Report files are written 0600 rather than at the process umask. Think about where you paste them.
  • Use a read-only SIEM token scoped to metadata searches if your platform supports it. That is enough for everything except sample_fields, which reads events to find out which fields a source carries and is off by default.
  • Give the host the narrowest network egress you can.
  • Store the SIEM token, client secret, webhook URLs and SMTP credentials as environment variables or secrets — never in the config file. The config only names them.
  • Leave verify_tls: true. A self-signed certificate on a search head is common; add its CA to the trust store rather than disabling verification, which exposes the token to anyone on-path. The tool logs a warning every run if you disable it.
  • Keep ai.enabled: false unless you have accepted sending a list of your undetectable techniques to a third-party API.
  • Point rules.sigma.path at a directory you would be willing to have read. The traversal is bounded and refuses symlinks, but a rule directory is still a tree of files this tool opens.
  • Run logcoverage coverage when you deploy, and again after any change to the credential's scopes or the export process. A report that says little because it could see little looks exactly like good coverage if you do not check.

There aren't any published security advisories