Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ follow [Semantic Versioning](https://semver.org/) from the first tagged release.

### Added
- `GOVERNANCE.md`, and a documented advisory and CVE process in `SECURITY.md`.
- Log-event schema in `docs/observability.md`, and tests that fail when a metric or a log field is added, renamed or retyped without being documented.
- CISA secure-by-design mapping in `docs/assurance.md`, including why monitor mode is the default.

### Changed
- The startup message that says monitor mode does not block is now a warning, so it shows at `-log-level warn`.
- `SECURITY.md` supported-versions table; `docs/releasing.md` states that breaking changes may land in a minor release below 1.0.

## [0.1.0] - 2026-10-17
Expand Down
2 changes: 1 addition & 1 deletion cmd/logshield/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer, ready fun
logger.Info("listening", "addr", ln.Addr().String(), "upstream", target.Redacted(),
"mode", settings.Mode, "tls", tlsCfg != nil, "version", version.String())
if settings.Mode == string(proxy.ModeMonitor) {
logger.Info("monitor mode: requests are recorded but never blocked; use -mode block to enforce")
logger.Warn("monitor mode: requests are recorded but never blocked; use -mode block to enforce")
}
if ready != nil {
ready(ln.Addr().String())
Expand Down
25 changes: 25 additions & 0 deletions docs/assurance.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,31 @@ Level 2**. Level 3 requires the build to run in an isolated reusable workflow
that the project's own steps cannot influence; this is a possible future
improvement, and the project does not claim it.

## CISA secure by design

How the project lines up with the [CISA Secure by Design](https://www.cisa.gov/securebydesign)
principles, and where it deliberately does not.

| Principle | In LogShield |
|---|---|
| Secure by default | Loopback-only listener, admin endpoints off, mandatory rules that cannot be disabled, fail closed on inspection errors, TLS 1.2 with forward-secret AEAD suites only, distroless non-root image that runs with a read-only root filesystem. |
| Take ownership of security outcomes | Stable rule IDs, a measured false-positive rate, a documented threat model that lists what is **not** detected, and a hardened profile (`examples/logshield.hardened.json`). |
| Radical transparency | Public advisories with CVE identifiers ([SECURITY.md](../SECURITY.md)), SPDX SBOM, signed provenance, reproducible archives, evidence and limits published ([lab.md](lab.md), [threat model](threat-model.md)). |
| Memory-safe language, few dependencies | Go, standard library only (no third-party Go modules). |
| Defence in depth, not one control | Documented as a compensating control: it does not replace patching ([ot-ics.md](ot-ics.md)). |

**The one default that is not "enforce": monitor mode.** `-mode` defaults to
`monitor`, so a fresh install records and counts detections but blocks nothing.
This is a deliberate, documented exception. A reverse proxy placed in front of
an application it has never seen can break real traffic, and the people who
deploy it into OT and legacy environments cannot afford an outage on first
start. The mitigations: the process logs a warning at startup that it is not
blocking; every request carries `mode` in its log line and metrics;
`logshield_monitor_would_block_total` shows exactly what block mode would have
stopped; and [deployment.md](deployment.md) makes switching to `block` the
second step of every rollout. A deployment that stays in monitor mode has
logging, not protection.

## NIST SSDF (SP 800-218), summary

| Practice group | How it is addressed |
Expand Down
32 changes: 32 additions & 0 deletions docs/observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,38 @@ stops accepting and finishes in-flight requests (up to 15 s).
| `logshield_requests_in_flight` | gauge | | Current concurrency. |
| `logshield_build_info` | gauge | `version` | Always 1. |

## Log events

Each request that is blocked, rejected, failed or flagged is one JSON line on
standard error (`slog` JSON handler). Clean requests are logged only at `debug`.
Every string is defanged, so a log line never contains a live `${`. The query
string is never logged, only its length. Field names are part of the public
surface versioned in [releasing.md](releasing.md).

`msg` is `request blocked`, `request rejected`, `request flagged` (a
suspicious or would-block request that was forwarded), `request failed`, or
`request` (debug).

| Field | Meaning |
|---|---|
| `id` | Request identifier, also sent upstream and in the response. |
| `mode` | `monitor` or `block`. |
| `action` | `forwarded`, `blocked`, `rejected` or `error`. |
| `status` | HTTP status sent to the client. |
| `decision` | Engine verdict: `clean`, `suspicious` or `malicious`. |
| `would_block` | True when monitor mode forwarded a request that block mode would have stopped. |
| `remote` | Client IP address. |
| `method`, `host`, `path` | Defanged and truncated request parts. |
| `query_bytes`, `body_bytes` | Sizes only; the content is not logged. |
| `duration_us`, `inspect_us` | Total time and engine time, in microseconds. |
| `reason` | Stable code for rejections and errors. |
| `body_truncated`, `body_skipped` | Only the first `-max-body` bytes were inspected, or the body type is on the skip list. |
| `failed_open` | Inspection failed and `-on-error open` forwarded the request anyway. |
| `budget` | The engine budget that was exhausted, if any. |
| `rules` | Rule IDs that fired, for example `["LSH-1001"]`. |
| `detections` | Up to 8 findings, each with `rule`, `severity`, `location`, `scheme`, `evidence` and `decoded_via` (the decoding layers that revealed it). |
| `detections_omitted` | How many further findings were not listed. |

Suggested alerts:

- `increase(logshield_inspection_errors_total[5m]) > 0` (a bug; report it).
Expand Down
37 changes: 37 additions & 0 deletions internal/proxy/docs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package proxy

import (
"os"
"regexp"
"testing"
)

// TestLogFieldsAreDocumented keeps the log-event table in docs/observability.md
// complete. Log field names are part of the public surface protected by the
// version number (docs/releasing.md), and SIEM rules depend on them, so a new or
// renamed field must be documented in the same change.
func TestLogFieldsAreDocumented(t *testing.T) {
src, err := os.ReadFile("respond.go")
if err != nil {
t.Fatal(err)
}
doc, err := os.ReadFile("../../docs/observability.md")
if err != nil {
t.Fatal(err)
}
fields := map[string]bool{}
for _, m := range regexp.MustCompile(`slog\.\w+\("([a-z_]+)"`).FindAllStringSubmatch(string(src), -1) {
fields[m[1]] = true
}
for _, m := range regexp.MustCompile("json:\"([a-z_]+)").FindAllStringSubmatch(string(src), -1) {
fields[m[1]] = true
}
if len(fields) < 20 {
t.Fatalf("found only %d log fields in respond.go; the scan is broken", len(fields))
}
for f := range fields {
if !regexp.MustCompile("`" + f + "`").Match(doc) {
t.Errorf("log field %q is not documented in docs/observability.md", f)
}
}
}
51 changes: 51 additions & 0 deletions internal/telemetry/docs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package telemetry

import (
"bytes"
"os"
"regexp"
"testing"
)

// TestMetricsMatchDocumentation keeps the metrics table in
// docs/observability.md equal to what /metrics really exposes. Metric names and
// types are part of the public surface protected by the version number (see
// docs/releasing.md), so adding, renaming or retyping one must be a deliberate,
// documented change.
func TestMetricsMatchDocumentation(t *testing.T) {
var out bytes.Buffer
if _, err := New("test").WriteTo(&out); err != nil {
t.Fatal(err)
}
exposed := map[string]string{}
for _, m := range regexp.MustCompile(`(?m)^# TYPE (logshield_\w+) (\w+)$`).FindAllStringSubmatch(out.String(), -1) {
exposed[m[1]] = m[2]
}
if len(exposed) == 0 {
t.Fatal("no metrics found in the exposition output")
}

raw, err := os.ReadFile("../../docs/observability.md")
if err != nil {
t.Fatal(err)
}
documented := map[string]string{}
for _, m := range regexp.MustCompile("(?m)^\\| `(logshield_\\w+)` \\| (\\w+) \\|").FindAllStringSubmatch(string(raw), -1) {
documented[m[1]] = m[2]
}

for name, typ := range exposed {
got, ok := documented[name]
switch {
case !ok:
t.Errorf("metric %s is exposed but not documented in docs/observability.md", name)
case got != typ:
t.Errorf("metric %s is a %s but documented as a %s", name, typ, got)
}
}
for name := range documented {
if _, ok := exposed[name]; !ok {
t.Errorf("metric %s is documented but not exposed", name)
}
}
}
Loading