Compare commits

..

7 Commits

Author SHA1 Message Date
f3nris 9fbe592421 feat(crowdstrike): version bounds and relative dates on device_count (1.7.0)
agent_version_lt/lte/gt/gte count the sensors below or above a build. FQL
compares versions as text, so the script scrolls the matching device IDs,
reads their versions and compares number by number. first_seen / last_seen
bounds also take a duration relative to now (24h, 7d).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgjV26rz6VcP262dX4vBrD
2026-10-19 20:09:31 +00:00
f3nris 6d36b4e958 feat(harfanglab): version bounds on endpoint_count (1.9.0)
version_lt, version_lte, version_gt and version_gte count the agents below or
above a build. The API matches a version only exactly, so the script pages
through the agents the other filters keep and compares number by number;
unreadable versions are reported apart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgjV26rz6VcP262dX4vBrD
2026-10-19 19:31:41 +00:00
f3nris 90c6c80843 feat(harfanglab): instance-level tenant scope (1.8.0)
An instance can carry the tenant of the client it serves, for clients sharing
one multi-tenant manager. endpoint_count uses it, for the count and the fleet
total beside it, when the block leaves its tenant filter empty; a tenant named
on the block still wins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgjV26rz6VcP262dX4vBrD
2026-10-19 19:23:40 +00:00
f3nris c67343b7cf feat(sentinelone): instance-level account scope (1.20.0)
An instance can carry the account IDs of the client it serves, for clients
sharing one multi-tenant console. count_agents, get_threats, get_alerts and
get_unified_alerts use them when the block leaves its account filter empty;
an account named on the block still wins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgjV26rz6VcP262dX4vBrD
2026-10-19 19:08:10 +00:00
f3nris 2bc7601177 fix(mail-templates): callout_title points to the badge in the mail language (1.0.4)
Its description gave "⚠ SLA ALERT" as the default, which read as a
value to type: a French mail ended up with an English box title. It now
says to leave the field empty for the badge in the mail language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJu1jvaug4qY1kMgz8wx41
2026-10-19 09:32:17 +00:00
f3nris 2a74bbc436 feat(mail-templates): markdown inputs in a multi-line field (1.0.3)
description and callout_text declare format: markdown, which Riposte
0.322+ edits in a text area that keeps line breaks; older versions
ignore it and \n still works. README documents format: multiline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJu1jvaug4qY1kMgz8wx41
2026-10-19 09:17:27 +00:00
f3nris 983414f278 fix(mail-templates): read \n as a line break in one-line inputs (1.0.2)
The playbook editor's input field is a single <input>, which drops the
newlines of a pasted text: the whole description reached the script as
one line starting with ## and rendered as a single heading. description
and callout_text without a real line break now take the two characters
\n as one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJu1jvaug4qY1kMgz8wx41
2026-10-19 09:10:52 +00:00
12 changed files with 232 additions and 26 deletions
+12
View File
@@ -141,6 +141,18 @@ issue_type_name:
Riposte refuses an `options_from` command that is not `risk: read`: opening a
field must never change anything in the customer's tool.
### Multi-line inputs
`format: multiline` (or `format: markdown`, which says what the text is) makes
the playbook editor show a resizable text area instead of a one-line field
(Riposte 0.322+). Use it for a mail body, a comment, a markdown description:
a one-line field turns the line breaks of a pasted text into spaces. Older
Riposte versions ignore `format` and keep the one-line field.
```yaml
description: { type: string, format: markdown, description: "Body in markdown" }
```
### `risk:` — the agent approval gate
Every command declares a tier. It is what Riposte's agent approval gate reads:
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
import datetime, json, os, re, sys, urllib.request, urllib.parse, urllib.error
SECRETS = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
INPUTS = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
@@ -61,6 +61,44 @@ RANGE_FILTERS = [
]
# A bound on first_seen / last_seen can also be relative to now ("24h", "7d",
# "30m"), so a scheduled playbook counts the sensors seen in the last day without
# computing a date. It is turned into the UTC instant here.
RELATIVE = re.compile(r"^\s*(\d+)\s*([mhd])\s*$", re.I)
UNITS = {"m": "minutes", "h": "hours", "d": "days"}
def instant(v):
m = RELATIVE.match(str(v))
if not m:
return v
delta = datetime.timedelta(**{UNITS[m.group(2).lower()]: int(m.group(1))})
return (datetime.datetime.now(datetime.timezone.utc) - delta).strftime("%Y-%m-%dT%H:%M:%SZ")
# Sensor version bounds. FQL compares agent_version as text, where 7.9 sorts
# above 7.18, so these are compared here, number by number, over the devices the
# other filters keep.
VERSION_BOUNDS = {
"agent_version_lt": lambda v, t: v < t,
"agent_version_lte": lambda v, t: v <= t,
"agent_version_gt": lambda v, t: v > t,
"agent_version_gte": lambda v, t: v >= t,
}
SCROLL = 5000
DETAILS = 1000
def parse_version(v):
parts = re.findall(r"\d+", str(v or ""))
return tuple(int(x) for x in parts) if parts else None
def pad(a, b):
n = max(len(a), len(b))
return a + (0,) * (n - len(a)), b + (0,) * (n - len(b))
def literal(v):
# FQL quotes strings with single quotes, so a value carrying one would end
# the literal early and change what the query means.
@@ -82,7 +120,7 @@ def build_fql(inputs):
for name, prop, operator in RANGE_FILTERS:
value = inputs.get(name)
if value:
clauses.append(prop + ":" + operator + literal(value))
clauses.append(prop + ":" + operator + literal(instant(value)))
# Escape hatch for any property this command does not name explicitly: raw
# FQL, ANDed with the rest.
raw = str(inputs.get("filter") or "").strip()
@@ -110,9 +148,59 @@ def count(tok, fql):
return int(total)
def version_bounds(inputs):
bounds = {}
for field in VERSION_BOUNDS:
value = str(inputs.get(field) or "").strip()
if value:
target = parse_version(value)
if target is None:
raise ValueError(field + " must be a version such as 7.18.18803, got: " + repr(value))
bounds[field] = target
return bounds
def count_by_version(tok, fql, bounds):
"""Every device ID the filter keeps, then their sensor versions, compared
here. A device with no readable version is left out and counted apart."""
ids, offset = [], None
while True:
params = [("limit", SCROLL)]
if fql:
params.insert(0, ("filter", fql))
if offset:
params.append(("offset", offset))
reply = call("GET", "/devices/queries/devices-scroll/v1", tok, params=params)
page = reply.get("resources") or []
ids.extend(page)
pagination = (reply.get("meta") or {}).get("pagination") or {}
offset = pagination.get("offset")
if not page or not offset or len(ids) >= int(pagination.get("total") or 0):
break
matched, unreadable = 0, 0
for i in range(0, len(ids), DETAILS):
reply = call("POST", "/devices/entities/devices/v2", tok, body={"ids": ids[i:i + DETAILS]})
for device in reply.get("resources") or []:
version = parse_version(device.get("agent_version"))
if version is None:
unreadable += 1
elif all(VERSION_BOUNDS[f](*pad(version, t)) for f, t in bounds.items()):
matched += 1
return matched, len(ids), unreadable
def main():
tok = token()
fql = build_fql(INPUTS)
bounds = version_bounds(INPUTS)
if bounds:
matched, scanned, unreadable = count_by_version(tok, fql, bounds)
result = {"count": matched, "filter": fql,
"agent_version_bounds": {f: INPUTS[f] for f in bounds},
"scanned": scanned, "unreadable_version": unreadable}
result["total"] = count(tok, "")
print(json.dumps(result))
return
result = {"count": count(tok, fql), "filter": fql}
# The estate total is the denominator the filtered count is read against: it
# turns a bare number into a proportion, and makes a filter that matched the
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
import json, os, re, sys, time, urllib.request, urllib.parse, urllib.error
RETRIES = 3
@@ -70,6 +70,28 @@ RANGE_FILTERS = {
}
# Version bounds. The API matches a version only exactly, and a range lookup it
# does not support would be dropped without a word — so these are compared here,
# number by number (4.10 is above 4.9, which a string comparison denies).
VERSION_BOUNDS = {
"version_lt": lambda v, t: v < t,
"version_lte": lambda v, t: v <= t,
"version_gt": lambda v, t: v > t,
"version_gte": lambda v, t: v >= t,
}
PAGE = 500
def parse_version(v):
parts = re.findall(r"\d+", str(v or ""))
return tuple(int(x) for x in parts) if parts else None
def pad(a, b):
n = max(len(a), len(b))
return a + (0,) * (n - len(a)), b + (0,) * (n - len(b))
def to_bool(v):
if isinstance(v, bool):
return v
@@ -86,7 +108,13 @@ def main():
"Content-Type": "application/json",
}
# === per-command logic ===
filters = {}
# An instance bound to one tenant of a shared, multi-tenant manager carries it
# in its config: it scopes the count, and the fleet total beside it, unless the
# block names a tenant itself.
scope = {}
if not str(inputs.get("tenant") or "").strip() and str(secrets.get("tenant") or "").strip():
scope["tenant"] = str(secrets["tenant"]).strip()
filters = dict(scope)
for field in EXACT_FILTERS:
value = inputs.get(field)
if value is None or value == "":
@@ -103,17 +131,48 @@ def main():
if len(kv) == 2 and kv[0].strip():
filters[kv[0].strip()] = kv[1]
bounds = {}
for field in VERSION_BOUNDS:
value = str(inputs.get(field) or "").strip()
if value:
target = parse_version(value)
if target is None:
raise ValueError(field + " must be a version such as 4.10.2, got: " + repr(value))
bounds[field] = target
# The agent list is a DRF paginated collection: `count` is the total number
# of matches, independent of the page size. limit=1 keeps the payload tiny.
agents = base + "/api/data/endpoint/Agent/"
if bounds:
# A version bound reads every agent the other filters keep and counts
# here. An agent with no readable version is left out and counted apart,
# rather than guessed above or below the bound.
count, scanned, unreadable, offset = 0, 0, 0, 0
while True:
page = request("GET", agents + qs(dict(filters, limit=PAGE, offset=offset)), headers)
rows = page.get("results") or []
for agent in rows:
scanned += 1
version = parse_version(agent.get("version"))
if version is None:
unreadable += 1
continue
if all(VERSION_BOUNDS[f](*pad(version, t)) for f, t in bounds.items()):
count += 1
offset += len(rows)
if not rows or offset >= page.get("count", 0):
break
result = {"count": count, "filters": dict(filters, **{f: inputs[f] for f in bounds}),
"scanned": scanned, "unreadable_version": unreadable}
else:
matched = request("GET", agents + qs(dict(filters, limit=1, offset=0)), headers)
result = {"count": matched.get("count", 0), "filters": filters}
# The API silently drops a query parameter it does not recognise as a
# filter, which yields the unfiltered total dressed up as a filtered count.
# Returning the fleet total alongside makes that failure visible.
if filters:
everything = request("GET", agents + qs({"limit": 1, "offset": 0}), headers)
if filters != scope or bounds:
everything = request("GET", agents + qs(dict(scope, limit=1, offset=0)), headers)
result["total"] = everything.get("count", 0)
else:
result["total"] = result["count"]
+5 -5
View File
@@ -1,8 +1,8 @@
id: mail_templates
name: Mail Templates (ACKnowledge)
version: 1.0.1
version: 1.0.4
description: "Turns a subject and a markdown description into an ACKnowledge SOC email (dark theme, header badge, signature with the logo, footer) — nothing is sent: chain the output into exchange-send-mail or mail-sender (`subject` → subject, `html_body` → html_body, `text_body` → body). Eight templates, each with its own badge and colour: sla_breach, incident_notification (colour follows `severity`), incident_update, incident_closure, action_required, escalation, threat_bulletin, info. The description takes markdown: headings (## is the blue capitalised section title), bold, italic, links, lists, quotes, rules, GFM tables, `code` and ``` blocks (hashes, IPs, command lines), and level pills `{{critical}}` `{{high}}` `{{medium}}` `{{low}}` `{{info}}` `{{ok}}` — `{{high:Élevée}}` changes the label. Raw HTML in the inputs is escaped, so alert data can be interpolated safely. The logo is an inline data-URI PNG: exchange-send-mail and mail-sender embed it; the Gmail integration does not, and the logo is lost there. No connection, no secret; stdlib-only."
changelog: "1.0.1 — The Test button failed (NameError: __file__) and render-mail-template printed nothing: Riposte runs a script with exec(), without __file__ or __name__. The test no longer imports the renderer and the renderer runs main() unconditionally. | 1.0.0 — Initial release: render-mail-template (8 templates, markdown description with tables, code and level pills, optional callout, action button, case reference and severity, SOC or client footer, French or English) and a self-test."
changelog: "1.0.4 — callout_title says to stay empty for the badge in the mail language: its example in English led to an English title in a French mail. | 1.0.3 — description and callout_text declare format: markdown: Riposte 0.322+ edits them in a multi-line text area that keeps line breaks (\\n still works on older versions). | 1.0.2 — The playbook editor's input field is a single line and drops pasted newlines, so a pasted description rendered as one heading: description and callout_text without a line break now read the two characters \\n as one. | 1.0.1 — The Test button failed (NameError: __file__) and render-mail-template printed nothing: Riposte runs a script with exec(), without __file__ or __name__. The test no longer imports the renderer and the renderer runs main() unconditionally. | 1.0.0 — Initial release: render-mail-template (8 templates, markdown description with tables, code and level pills, optional callout, action button, case reference and severity, SOC or client footer, French or English) and a self-test."
category: email
# Nothing to connect to. The only setting names the bot in the SOC footer
@@ -27,9 +27,9 @@ commands:
enum: [sla_breach, incident_notification, incident_update, incident_closure, action_required, escalation, threat_bulletin, info]
description: "Layout: sla_breach (red, SLA ALERT), incident_notification (colour of `severity`), incident_update (blue, STATUS UPDATE), incident_closure (green, CLOSED), action_required (orange), escalation (magenta), threat_bulletin (violet), info (grey, default)"
subject: { type: string, description: "Subject, also shown as the title of the mail" }
description: { type: string, description: "Body in markdown — ## section titles, **bold**, lists, tables, `code`, ``` blocks, {{medium}} pills" }
callout_text: { type: string, description: "Highlighted box above the description (markdown). Empty: no box" }
callout_title: { type: string, description: "Title of the box (default: the template badge, e.g. ⚠ SLA ALERT)" }
description: { type: string, format: markdown, description: "Body in markdown — ## section titles, **bold**, lists, tables, `code`, ``` blocks, {{medium}} pills. On Riposte before 0.322 (one-line field), write each line break as \\n" }
callout_text: { type: string, format: markdown, description: "Highlighted box above the description (markdown). Empty: no box" }
callout_title: { type: string, description: "Title of the box. Leave empty for the template badge in the mail language (⚠ ALERTE SLA in fr, ⚠ SLA ALERT in en)" }
button_label: { type: string, description: "Action button text, e.g. Open the case in Riposte (needs button_url)" }
button_url: { type: string, description: "Action button link (http or https)" }
case_ref: { type: string, description: "Case or ticket reference, shown under the title and prefixed to the subject" }
@@ -405,6 +405,16 @@ def _when(raw, lang):
return "%d %s %d, %s UTC" % (d.day, STRINGS["en"]["months"][d.month - 1], d.year, d.strftime("%H:%M"))
def _lines(v):
"""The markdown with its line breaks. The playbook editor's input field is a
single line and drops pasted newlines, so a text without one may spell them
as the two characters \\n instead."""
v = str(v or "").strip()
if "\n" not in v and "\\n" in v:
v = v.replace("\\r\\n", "\n").replace("\\n", "\n")
return v
def _choice(inputs, key, allowed, default):
v = _s(inputs.get(key)).lower() or default
if v not in allowed:
@@ -417,7 +427,7 @@ def render(cfg, inputs):
lang = _choice(inputs, "lang", ["fr", "en"], "fr")
audience = _choice(inputs, "audience", ["soc", "client"], "soc")
subject = _s(inputs.get("subject"))
description = str(inputs.get("description") or "").strip()
description = _lines(inputs.get("description"))
if not subject:
raise Exception("subject is required")
if not description:
@@ -435,7 +445,7 @@ def render(cfg, inputs):
raise Exception("button_url must start with http:// or https://")
if button_label and not button_url:
raise Exception("button_url is required with button_label")
callout_text = str(inputs.get("callout_text") or "").strip()
callout_text = _lines(inputs.get("callout_text"))
bot = _s(cfg.get("bot_name")) or "Riposte"
t = TEMPLATES[template]
File diff suppressed because one or more lines are too long
@@ -20,6 +20,10 @@ FALSE_WORDS = {"false", "no", "0"}
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
# An instance bound to one account of a shared, multi-tenant console carries it
# in its config: it scopes the call unless the block names accounts itself.
if not str(inputs.get("accountIds") or "").strip() and str(secrets.get("account_ids") or "").strip():
inputs["accountIds"] = str(secrets["account_ids"])
base = secrets.get("url", "").rstrip("/") + "/web/api/v2.1"
headers = {
"Authorization": "ApiToken " + secrets.get("api_token", ""),
@@ -16,6 +16,10 @@ def csv(v):
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
# An instance bound to one account of a shared, multi-tenant console carries it
# in its config: it scopes the call unless the block names accounts itself.
if not str(inputs.get("account_ids") or "").strip() and str(secrets.get("account_ids") or "").strip():
inputs["account_ids"] = str(secrets["account_ids"])
base = secrets.get("url", "").rstrip("/") + "/web/api/v2.1"
headers = {
"Authorization": "ApiToken " + secrets.get("api_token", ""),
@@ -12,6 +12,10 @@ def request(method, url, headers, body=None):
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
# An instance bound to one account of a shared, multi-tenant console carries it
# in its config: it scopes the call unless the block names accounts itself.
if not str(inputs.get("account_ids") or "").strip() and str(secrets.get("account_ids") or "").strip():
inputs["account_ids"] = str(secrets["account_ids"])
base = secrets.get("url", "").rstrip("/") + "/web/api/v2.1"
headers = {
"Authorization": "ApiToken " + secrets.get("api_token", ""),
@@ -301,6 +301,10 @@ def sort_clause(form, by):
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
# An instance bound to one account of a shared, multi-tenant console carries it
# in its config: it scopes the call unless the block names accounts itself.
if not str(inputs.get("account_ids") or "").strip() and str(secrets.get("account_ids") or "").strip():
inputs["account_ids"] = str(secrets["account_ids"])
base = secrets.get("url", "").rstrip("/") + "/web/api/v2.1"
headers = {
"Authorization": "ApiToken " + secrets.get("api_token", ""),