Audit log

The audit log records what the Artifact Firewall decided. It is the record of what was blocked and why, and in report mode it is the record of what would have been blocked.

An entry is written for each request the firewall decided something about, and its kind says which sort of request that was:

  • eval: an evaluation through /api/eval. This is how the Virtual Registry authorizes an artifact download, so each download through a registry with the firewall enabled writes one, allowed or not.
  • listing: a manifest that the firewall acted on while filtering it, such as an npm packument or a PyPI simple index. One entry covers the whole manifest and names the versions it acted on in decisions, so a npm install of a package with fifty versions, three of them denied, writes one listing entry naming those three and one eval entry for the version that was fetched.
  • download: a request for one artifact that passed through the firewall proxy rather than /api/eval, allowed or not.

The firewall only sees a manifest that Varnish fetches, so one served from the Virtual Registry’s cache, or revalidated with a 304, writes no entry. Nor does a manifest passed on unchanged. In report mode, and with preserve_manifest, that is every manifest except one for a package that a rule denies as a whole, whose entry has no decisions.

Format

Each entry is one JSON line.

{"id":"01a10b57-d338-7a3b-9d52-4f0e8c1a7b3d","ts":"2026-10-05T09:14:27.512Z","kind":"eval","purl":"pkg:npm/%40company/foo@1.2.3","rule_id":"block-vulnerable","ruleset_id":"security-policy","action":"deny","enforced_action":"deny","registry":"npmjs","mode":"normal","outcome":"blocked"}
  • id: unique ID of the entry, a version 7 UUID, so IDs sort in the order the entries were written.
  • ts: when the entry was written, in RFC 3339 form in UTC with milliseconds, for example 2026-10-05T09:14:27.512Z.
  • kind: eval, listing or download, as described above.
  • purl: Package URL of the artifact or package that was requested. A NuGet package name is logged lowercased, whatever case the request used. Omitted from a listing entry that covers several versions, whose decisions name them instead.
  • rule_id: ID of the rule that matched, or default when no rule matched and default_action decided.
  • rule_system_id: numeric key a control plane assigned to the rule when it stored it. A ruleset read from a path or a git source carries none, and the field is then omitted.
  • ruleset_id: ID of the ruleset holding that rule, or default.
  • ruleset_system_id: numeric key a control plane assigned to the ruleset, omitted under the same conditions as rule_system_id.
  • action: what the rule asked for, one of allow, hide or deny.
  • enforced_action: what was enforced, once the firewall mode is applied. Before Artifact Firewall 0.7.0 this field was named effective_action.
  • registry: name of the virtual registry the request came through, as the Virtual Registry passed it to the firewall. Omitted when the request names none.
  • mode: the firewall mode the decision was taken in: normal, hide or report.
  • outcome: what the client got. served when the response passed through as the remote sent it, filtered when the firewall altered it by acting on versions in it, and blocked when the firewall refused the request, as it refuses a download that a rule denies, which the Virtual Registry turns into a 403. A hidden version is served, since hide keeps a version out of “latest” but still lets a client download it by name.
  • decisions: the versions a listing entry acted on, described below. Omitted when there are none.
  • cached and decided_at: set on a listing entry for a manifest that the firewall served from its own cache, described below.

rule_id, ruleset_id, their system IDs, action and enforced_action describe the rule that decided the whole request. A listing entry for a manifest filtered version by version has no such rule and omits them, and its rules are in decisions.

The two action fields differ when the firewall mode downgrades a decision. In normal mode they are always equal. In hide mode a denied package is logged as "action":"deny","enforced_action":"hide" with "outcome":"served". In report mode every package is logged with "enforced_action":"allow" and served, so a denied one reads "action":"deny","enforced_action":"allow". outcome is present on every entry, so a query filtering on it shows what reached the client whatever the mode and the kind.

Decisions

{"id":"01a10b57-d2e6-7c41-8e63-2b9d4f1a6c08","ts":"2026-10-05T09:14:27.430Z","kind":"listing","registry":"npmjs","mode":"normal","outcome":"filtered","decisions":[{"purl":"pkg:npm/%40company/foo@1.2.3","effect":"flagged","enforced_effect":"flagged","action":"deny","rule_id":"block-vulnerable","ruleset_id":"security-policy"},{"purl":"pkg:npm/%40company/foo@1.4.0","effect":"quarantined","enforced_effect":"quarantined","action":"allow","rule_id":"default","ruleset_id":"default","quarantine_days":7,"published_at":"2026-10-01T08:00:00.000Z"}]}

Each element of decisions is one version the firewall acted on, named by its full purl. It carries the rule_id, ruleset_id, system IDs and action of the rule that matched it, and two more fields:

  • effect: what the verdict did to the version.
    • dropped: removed from the manifest.
    • flagged: still listed, but marked or passed over, as npm does with every denied or hidden version and PyPI with a hidden one, which it marks yanked. A flagged version is not necessarily blocked: under a hide rule it can still be downloaded by name.
    • quarantined: withheld for being younger than the quarantine window.
  • enforced_effect: what the mode let happen, which is the effect itself or none.

A quarantined version matched a rule that allowed it, often the default, and was then withheld for its age, so its action is allow. It also carries quarantine_days, the window that applied, from the rule’s quarantine_days or else default_quarantine_days, and published_at, the publish time it was measured from. The version is released at published_at plus quarantine_days, while a dropped or flagged one stays withheld until the rule changes.

Cached manifests

The firewall keeps a filtered conda repodata.json and RPM primary.xml and repomd.xml, and serves them again without filtering them anew. The entry for such a response repeats the decisions of the request that filtered it, with "cached":true, and decided_at holding when those decisions were taken. Its ts is when it was served.

Destinations

audit_log_output takes one destination:

  • stdout: JSON on standard output. This is the default, and under Docker or Kubernetes it puts the audit log in the container log.
  • stderr: JSON on standard error.
  • A file path: JSON appended to that file.
  • otel: OpenTelemetry log records instead of JSON, described below.
  • An empty string: no audit log.

A JSON destination and otel are mutually exclusive, so exporting over OpenTelemetry means the JSON stream stops.

Rotating a file

The file is held open for the life of the process and reopened on SIGHUP, so a rotation tool can rename it and then signal, as with the Supervisor’s own log. Within Orca the signal goes to the Supervisor, which also reloads its configuration.

Exporting over OpenTelemetry

With audit_log_output: otel, each entry becomes an OpenTelemetry log record instead of a JSON line, carrying the same fields as attributes. ts is the record’s timestamp rather than an attribute, and rule_system_id, ruleset_system_id, cached and decided_at are left off when they would be empty, as in the JSON. An attribute cannot hold a list, so decisions is replaced by counts: decisions_count for all of them, and versions_dropped, versions_flagged and versions_quarantined for those whose enforced_effect is each effect. The per-version detail is only in the JSON. The body is package evaluation, and the severity is WARN when the enforced action is deny or hide or the outcome is filtered or blocked, and INFO otherwise. Records appear under the instrumentation scope artifact-firewall/audit, separate from the process log, and each one is correlated with the trace of the request that produced it.

The records travel through the same logs pipeline as the rest of Orca, so otel.logs has to be configured with an endpoint in the same configuration file. Without one there is nowhere to export to, and the entries are dropped rather than falling back to JSON.


®Varnish Software, Wallingatan 12, 111 60 Stockholm, Organization nr. 556805-6203