Bound the Time Range of Splunk Searches
Makes sure every Splunk search the agent runs has a bounded time range.
On this page
What it does
Makes sure every Splunk search the agent runs has a bounded time range. A search with no
earliest= runs over all time, which is slow, expensive, and pulls far more data than an
investigation needs. This policy rewrites the SPL before it reaches Splunk:
- No time range given →
earliest=-24his added. The main search and every subsearch are checked separately, because Splunk gives each one its own time range: a bounded subsearch doesn't bound the main search, and the other way round. - A relative range longer than 7 days (
earliest=-30d,earliest=-1y@y) → replaced withearliest=-7d. Snapping counts:@y,@q,@mon, or-1d@wcan reach back further than the number in front of it, so the policy assumes the furthest it could go. - All time or an old epoch (
earliest=0,earliest=1, an epoch more than 7 days ago) → replaced withearliest=-7d. - A relative expression it can't read (an unknown unit, chained offsets) → replaced with
earliest=-7d, so it is always bounded. - Ranges of 7 days or less are left alone.
If the tool takes a separate earliest_time argument (custom Splunk tools and community
servers do), that argument is bounded the same way instead of editing the SPL.
Compliance alignment
- GDPR Art. 5(1)(c) — data minimisation: each agent search reads a bounded slice of log data.
- SOC 2 CC6.1 — limits the volume of data the agent channel can pull per call.
Why ingress
The time range is part of the request. Bounding it before the search runs is the only way to keep Splunk from scanning all time.
How it matches
Scope is tools whose lowercased name starts with splunk. The policy edits the first of the
query, search, or spl arguments that is present (splunk_run_query on Splunk's
official MCP Server app uses query). Each earliest= in the SPL, including inside
subsearches, is checked on its own.
The main search and each subsearch ([ ... ], nested or not) is a separate scope. Brackets
inside double-quoted strings, like a rex character class, are not subsearches. A scope with
no earliest= of its own gets earliest=-24h right after its leading search keyword, or
at its start. Subsearches that start with a generating command ([| inputlookup …]) are
skipped. When the tool has an earliest_time argument, it sets the range for the whole job,
so nothing is added to the SPL.
Relative units understood: seconds, minutes, hours, days, weeks, months (30 days), quarters
(90 days), and years (365 days), in Splunk's short and long spellings. A relative time is
read as [offset][@snap][offset] (-1d@d, @y, @d-3h). Its lookback is the offset back,
plus the most a snap can add (a full unit: up to 31 days for @mon, 92 for @q, 366 for
@y, 7 for @w0–@w7), plus any offset back after the snap. So -7d@d counts as 8 days
and is trimmed, and -6d@d is left alone.
Configuration
default_earliest (the range added when none is given) and max_lookback_days at the top of
the policy.
Examples
Range added
{
"input": {
"action": "tool_pre_invoke",
"resource": { "name": "splunk-splunk_run_query", "type": "tool" },
"payload": { "args": { "query": "search index=payments timeout | stats count by host" } }
}
}
transform.transformed_payload.query = "search earliest=-24h index=payments timeout | stats count by host".
Range trimmed
{
"input": {
"action": "tool_pre_invoke",
"resource": { "name": "splunk-splunk_run_query", "type": "tool" },
"payload": { "args": { "query": "index=payments earliest=-90d timeout" } }
}
}
transform.transformed_payload.query = "index=payments earliest=-7d timeout".
Composition
Pairs with splunk/block-write-commands and
splunk/redact-secrets-egress.
Known limitations
- Generating commands and SPL2 are skipped. Queries that start with
|(| tstats,| inputlookup,| rest) or@spl2are not rewritten, because addingearliest=at the front would break them.tstatssearches are usually cheap; addearliest=inside itswhereclause yourself if needed. - Absolute dates pass.
earliest=10/01/2026:00:00:00and other formatted dates are not parsed and are left as written. latest=is not checked. A search likeearliest=-7d latest=-6dis narrower, not wider, so onlyearliestmatters for the cap.- Snaps are counted at their widest. The policy doesn't know the time of day or your
Splunk timezone, so
-7d@d(at most 8 days) is trimmed to-7deven when it would have reached back only a few hours past 7 days. - Unbalanced brackets. If the unquoted
[and]in a query don't pair up, the whole query is treated as one search, as before. - Server name prefix. Scope is tools whose name starts with
splunk. Change_is_splunkif your server is registered under another name.
Compliance note. This policy supports alignment with the cited framework controls on the MCP path only. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it.
Policy source (Rego)
package splunk.ingress.cap_search
# Transform-only policy — never denies, only bounds the search time range.
default allow := true
default_earliest := "-24h"
max_lookback_days := 7
_cap := sprintf("-%dd", [max_lookback_days])
_max_seconds := max_lookback_days * 86400
# Seconds per Splunk relative-time unit.
_unit_seconds := {
"s": 1, "sec": 1, "secs": 1, "second": 1, "seconds": 1,
"m": 60, "min": 60, "mins": 60, "minute": 60, "minutes": 60,
"h": 3600, "hr": 3600, "hrs": 3600, "hour": 3600, "hours": 3600,
"d": 86400, "day": 86400, "days": 86400,
"w": 604800, "week": 604800, "weeks": 604800,
"mon": 2592000, "month": 2592000, "months": 2592000,
"q": 7776000, "qtr": 7776000, "qtrs": 7776000, "quarter": 7776000, "quarters": 7776000,
"y": 31536000, "yr": 31536000, "yrs": 31536000, "year": 31536000, "years": 31536000,
}
_tool := lower(object.get(input.resource, "name", ""))
_args := object.get(object.get(input, "payload", {}), "args", {})
_is_splunk if startswith(_tool, "splunk")
# The SPL argument to edit: the first of query / search / spl that is a non-empty string.
_spl_keys := [k |
some k in ["query", "search", "spl"]
v := object.get(_args, k, null)
is_string(v)
trim_space(v) != ""
]
_spl_key := _spl_keys[0] if count(_spl_keys) > 0
_spl := object.get(_args, _spl_key, "")
# --- Is an earliest value too wide? ---
# Most a snap to the start of a unit can reach back. Calendar units use their
# longest length; a week snap (@w, @w0–@w7) reaches back at most 7 days.
_snap_seconds(u) := 2678400 if _unit_seconds[u] == 2592000 # month: 31 days
_snap_seconds(u) := 7948800 if _unit_seconds[u] == 7776000 # quarter: 92 days
_snap_seconds(u) := 31622400 if _unit_seconds[u] == 31536000 # year: 366 days
_snap_seconds(u) := secs if {
secs := _unit_seconds[u]
not secs in {2592000, 7776000, 31536000}
}
# A relative time: [offset][@snap[digit]][offset], e.g. -30d, -1y@y, @mon, @d-3h, -1d@w0.
_relative_pattern := `^(?:([+-])(\d*)([a-z]+))?(?:@([a-z]+?)(\d?))?(?:([+-])(\d*)([a-z]+))?$`
_amount(n) := 1 if n == ""
_amount(n) := to_number(n) if n != ""
# Seconds an offset moves back in time. Forward offsets (+) count as zero.
_back("", _, _) := 0
_back("+", _, u) := 0 if _unit_seconds[u]
_back("-", n, u) := _amount(n) * _unit_seconds[u]
_snap_back("") := 0
_snap_back(u) := _snap_seconds(u) if u != ""
# Furthest a relative time can reach back, in seconds. Undefined when the value
# isn't a relative time this policy can read (an unknown unit, chained offsets).
_lookback(v) := pre + snap + post if {
m := regex.find_all_string_submatch_n(_relative_pattern, lower(v), 1)
count(m) == 1
g := m[0]
concat("", [g[1], g[4]]) != "" # needs an offset or a snap
pre := _back(g[1], g[2], g[3])
snap := _snap_back(g[4])
post := _back(g[6], g[7], g[8])
}
# Relative (offset and/or snap) and reaching back further than the cap.
_too_wide(v) if _lookback(v) > _max_seconds
# Looks relative but can't be read: bound it rather than let it through.
_too_wide(v) if {
regex.match(`^[-+@]`, v)
not _lookback(v)
}
# All time, or an epoch more than the cap in the past.
_too_wide(v) if {
regex.match(`^\d+(\.\d+)?$`, v)
(time.now_ns() / 1000000000) - to_number(v) > _max_seconds
}
_too_wide(v) if v == ""
# --- SPL edits ---
_skip_spl if startswith(trim_space(_spl), "|")
_skip_spl if startswith(lower(trim_space(_spl)), "@spl2")
# Each earliest=<value> token in the SPL (value optionally quoted).
_earliest_tokens contains [m[0], m[1]] if {
some m in regex.find_all_string_submatch_n(`(?i)\bearliest\s*=\s*"?([^\s"\]|]*)"?`, _spl, -1)
}
_wide_tokens := {tok[0]: sprintf("earliest=%s", [_cap]) |
some tok in _earliest_tokens
_too_wide(tok[1])
}
_has_earliest_arg if is_string(object.get(_args, "earliest_time", null))
# --- Search scopes: the main search and each [subsearch] ---
_chars := split(_spl, "")
_n := count(_chars)
# Double quotes that open or close a string (not escaped with a backslash).
_quotes := [i | some i, c in _chars; c == "\""; not _escaped(i)]
_escaped(i) if {
i > 0
_chars[i - 1] == "\\"
}
_in_quotes(i) if count([q | some q in _quotes; q < i]) % 2 == 1
# Unquoted brackets, in position order. Brackets inside a quoted string (a rex
# character class, say) are not subsearches.
_brackets := sort([[i, c] |
some i, c in _chars
c in {"[", "]"}
not _in_quotes(i)
])
# Subsearch depth just before position i.
_depth_before(i) := count([1 | some b in _brackets; b[0] < i; b[1] == "["]) - count([1 | some b in _brackets; b[0] < i; b[1] == "]"])
_balanced if {
_depth_before(_n) == 0
every b in _brackets {
_depth_before(b[0] + 1) >= 0
}
}
# [content start, content end, depth] for each subsearch.
_subsearches contains [s, e, d] if {
_balanced
some b in _brackets
b[1] == "["
d := _depth_before(b[0]) + 1
closes := [c[0] | some c in _brackets; c[1] == "]"; c[0] > b[0]; _depth_before(c[0]) == d]
count(closes) > 0
e := min(closes)
s := b[0] + 1
}
_scopes := array.concat([[0, _n, 0]], [sc | some sc in _subsearches])
# A scope's own text, with nested subsearches left out.
_own_text(sc) := _spl if {
sc[2] == 0
not _balanced
}
_own_text(sc) := concat("", [_chars[k] | some k in numbers.range(sc[0], sc[1] - 1); _depth_before(k) == sc[2]]) if {
_balanced
sc[1] > sc[0]
}
_own_text(sc) := "" if {
_balanced
sc[1] <= sc[0]
}
_needs_default(own) if {
t := trim_space(own)
t != ""
not startswith(t, "|")
not startswith(lower(t), "@spl2")
not regex.match(`(?i)\bearliest\s*=`, own)
}
# Where earliest= goes in a scope: after a leading "search" keyword, or at the
# first non-space character.
_insert_offset(content) := count(m[0][0]) if {
m := regex.find_all_string_submatch_n(`(?i)^\s*search\s+`, content, 1)
count(m) == 1
}
_insert_offset(content) := count(content) - count(trim_left(content, " \t\r\n")) if {
not regex.match(`(?i)^\s*search\s+`, content)
}
_default_positions := sort([p |
not _has_earliest_arg
some sc in _scopes
_needs_default(_own_text(sc))
p := sc[0] + _insert_offset(substring(_spl, sc[0], sc[1] - sc[0]))
])
_insert_text := sprintf("earliest=%s ", [default_earliest])
_with_defaults := _spl if count(_default_positions) == 0
_with_defaults := concat(_insert_text, parts) if {
ps := _default_positions
count(ps) > 0
starts := array.concat([0], ps)
ends := array.concat(ps, [_n])
parts := [substring(_spl, starts[i], ends[i] - starts[i]) | some i in numbers.range(0, count(ps))]
}
_spl_changed if count(_wide_tokens) > 0
_spl_changed if count(_default_positions) > 0
_new_spl := strings.replace_n(_wide_tokens, _with_defaults) if {
not _skip_spl
_spl_changed
}
# --- earliest_time argument (custom / community tools) ---
_new_earliest_arg := _cap if {
_has_earliest_arg
_too_wide(trim_space(object.get(_args, "earliest_time", "")))
}
# --- Transform ---
_patch[_spl_key] := _new_spl if _is_splunk
_patch["earliest_time"] := _new_earliest_arg if _is_splunk
transform := {"transformed_payload": object.union(_args, _patch)} if {
input.action == "tool_pre_invoke"
count(_patch) > 0
} Canonical source: policy.md on GitHub · raw · raw on this site (.md)