From 7b075da3a1d7c252dfa638ea4d6e90454321bcdf Mon Sep 17 00:00:00 2001 From: Pranav Verma Date: Sat, 17 Oct 2026 14:41:14 +0000 Subject: [PATCH] Pin the metrics and log schema to the docs, add the CISA mapping, warn when monitoring --- CHANGELOG.md | 3 ++ cmd/logshield/main.go | 2 +- docs/assurance.md | 25 ++++++++++++++++ docs/observability.md | 32 +++++++++++++++++++++ internal/proxy/docs_test.go | 37 ++++++++++++++++++++++++ internal/telemetry/docs_test.go | 51 +++++++++++++++++++++++++++++++++ 6 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 internal/proxy/docs_test.go create mode 100644 internal/telemetry/docs_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index f1fcf74..e268020 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/cmd/logshield/main.go b/cmd/logshield/main.go index 480aab8..00d2f35 100644 --- a/cmd/logshield/main.go +++ b/cmd/logshield/main.go @@ -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()) diff --git a/docs/assurance.md b/docs/assurance.md index 2d47989..a96dd8c 100644 --- a/docs/assurance.md +++ b/docs/assurance.md @@ -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 | diff --git a/docs/observability.md b/docs/observability.md index 602636f..63d2fef 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -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). diff --git a/internal/proxy/docs_test.go b/internal/proxy/docs_test.go new file mode 100644 index 0000000..1cabde2 --- /dev/null +++ b/internal/proxy/docs_test.go @@ -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) + } + } +} diff --git a/internal/telemetry/docs_test.go b/internal/telemetry/docs_test.go new file mode 100644 index 0000000..02e2fac --- /dev/null +++ b/internal/telemetry/docs_test.go @@ -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) + } + } +}