diff --git a/CHANGELOG.md b/CHANGELOG.md
index 31de4d7..a1e105c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,28 @@
# Changelog
+## 0.5.4
+
+- Remove unsupported `local` options from lookup searches and correct the builder guidance. Existing searches using them show instructions to remove the option.
+- Increase builder labels, inputs, and help text, plus submission table and receipt text, for readability.
+- Replace the Scan icon with a targeting reticle and heartbeat line.
+
+## 0.5.3
+
+- Query simple KV Store lookups directly instead of dispatching search jobs. Preserve SPL execution for transformations, filtered definitions, and multivalue expansion.
+- Match pasted lookup values without regard to case, remove case-only duplicates, and send the stored lookup values to SOAR.
+- Show playbook and action totals with status counts on ten-row submission pages. Refresh only the current page.
+- Use custom action run names in receipts. Include reported format, filter, decision, code, and utility results; show unknown when SOAR does not report a block status.
+- Enable SOAR automation by default for new forms and add Scan, Server, and User icons. Existing and cloned forms retain their automation setting.
+- Group field types and configuration into collapsible sections. Keep navigation visible while scrolling and prevent profile avatars from shrinking into ovals.
+
+## 0.5.2
+
+- Add static text to the form builder with plain, information, and warning styles and conditional visibility.
+- Accept comma-, newline-, and semicolon-separated lists in multiple-value text and lookup fields. Lookup lists are checked together before adding, and duplicate values are removed.
+- Keep lookup suggestions inside the form layout so results are not clipped at the bottom of a panel.
+- Allow `local=true` and `local=false` before or after the inputlookup name.
+- Reduce lookup overhead by skipping occupied rate-limit slots, waiting for search completion during dispatch, and caching recent suggestions for 30 seconds. Submission still revalidates lookup values.
+
## 0.5.1
- Set `is_configured = false` in the distributed app configuration.
diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md
index e537d83..cb7953a 100644
--- a/DEPLOYMENT.md
+++ b/DEPLOYMENT.md
@@ -47,9 +47,11 @@ The integration uses SOAR's REST APIs. SOAR Cloud exposes the same intake APIs,
## Forms and automation
-Use **Form builder** to add fields, validation, lookup searches, sections, and access rules. **Preview → Test validation** checks the form without creating a SOAR event. Save a draft while editing; publish to apply changes to new submissions.
+Use **Form builder** to add fields, validation, lookup searches, sections, and access rules. Field types and settings are grouped into collapsible sections. **Preview → Test validation** checks the form without creating a SOAR event. Save a draft while editing; publish to apply changes to new submissions.
-Under **SOAR mapping**, choose a label, tags, and optional CEF mappings. **Allow SOAR automation on delivery** permits automation on the final submission artifact. Configure an active SOAR playbook for the container label. Delivery success means the event and artifact were created; execution status is shown separately.
+**Static text** adds a heading and message without collecting input. Choose plain text for section descriptions, or Information or Warning for a callout. Use **Show condition** to display it when an earlier field has a particular value. Line breaks are preserved; HTML is displayed as text. Static text is not included in submitted inputs or SOAR field mappings.
+
+Under **SOAR mapping**, choose a label, tags, and optional CEF mappings. **Allow SOAR automation on delivery** starts enabled for new forms and permits automation on the final submission artifact. Existing and cloned forms keep their saved setting. Configure an active SOAR playbook for the container label. Delivery success means the event and artifact were created; execution status is shown separately.
The **Approvals** tab marks requests that require approval. The SOAR playbook must enforce that requirement before performing actions. ActionStack does not collect approval decisions.
@@ -57,7 +59,7 @@ The **Approvals** tab marks requests that require approval. The SOAR playbook mu
## Lookup fields
-Single-value and multiple-value lookup fields run SPL as the requesting Splunk user. That user needs search capability and read access to the lookup in the selected app namespace.
+Single-value and multiple-value lookup fields use the requesting Splunk user’s permissions in the selected app namespace. SPL searches require search capability and read access to the lookup. Simple KV Store lookups can use direct collection reads with that same user’s read permissions.
```spl
| inputlookup identity_lookup_expanded
@@ -67,9 +69,13 @@ Single-value and multiple-value lookup fields run SPL as the requesting Splunk u
Set **Value field sent to SOAR** to `identity` and **Display label field** to `display_name`. Keep both in the final results. Suggestions match either field; only selected values are submitted and revalidated.
-Searches must start with `inputlookup`. Supported transformations are `eval`, `where`, `search`, `fields`, `table`, `rename`, `dedup`, `sort`, `head`, `tail`, `fillnull`, `rex`, `regex`, `spath`, `stats`, `eventstats`, `streamstats`, `mvexpand`, `makemv`, `mvcombine`, `nomv`, `convert`, and `replace`. Macros, subsearches, custom commands, and write commands are not supported.
+Searches must start with `inputlookup`. ActionStack adds `strict=true` so lookup errors fail the search. The `inputlookup` command does not support `local=true` or `local=false`; remove these options from any existing form searches and republish the form. Supported transformations are `eval`, `where`, `search`, `fields`, `table`, `rename`, `dedup`, `sort`, `head`, `tail`, `fillnull`, `rex`, `regex`, `spath`, `stats`, `eventstats`, `streamstats`, `mvexpand`, `makemv`, `mvcombine`, `nomv`, `convert`, and `replace`. Macros, subsearches, custom commands, and write commands are not supported.
+
+For a KV Store lookup whose output fields are declared `string` in `collections.conf`, with only `fields` or `table` projections, ActionStack resolves the lookup definition and reads matching records directly from the collection. No search job is created. Scalar `mvexpand` also uses this path; array results fall back to SPL. Definitions with filters or time fields, CSV lookups, and other transformations use SPL so their semantics are preserved. Direct reads require access to the lookup definition, collection configuration, and underlying collection; otherwise the app tries the normal search path. For the fastest experience, materialize expensive transformations into a KV Store lookup and use a simple projection.
-The default search delay is 50 ms after at least three characters. Results are limited to 25 prefix matches. Search jobs have a five-second execution limit. Each form supports up to five lookup fields; multiple-value fields accept up to 25 items. Large lookups may require scans even when results are limited.
+The default search delay is 50 ms after at least three characters. Results are limited to 25 prefix matches. Search jobs have a five-second execution limit. Each form supports up to five lookup fields; multiple-value fields accept up to 25 items. Recent suggestions are cached in the field for 30 seconds; submission checks always run against the lookup again. Large lookups may require scans even when results are limited.
+
+Multiple-value text and lookup fields accept comma-, newline-, or semicolon-separated lists. Paste a list, or type it and press Enter or **Add values**. For lookups, use values from the configured SOAR value field (matching ignores case); use search suggestions to select by display label. A batch with unknown values is rejected without adding a partial list. Duplicate values are removed. Lookup verification also removes case-only duplicates and returns the stored value, including its casing, before validation and delivery.
## Permissions
@@ -89,7 +95,9 @@ Lookup searches use the requesting user's session. Application storage uses serv
## Submissions and recovery
-**Submissions** shows authorized requests in the selected workspace. **My submissions** filters to the signed-in user. Receipts poll SOAR status every 30 seconds while open and visible. Summary and result data are size-limited; use SOAR for complete results.
+**Submissions** shows authorized requests in the selected workspace. **My submissions** filters to the signed-in user. The list shows ten requests per page with playbook/action totals and colored status counts. Only the current page is polled, with at most three status requests in flight. Receipts poll SOAR status every 30 seconds while open and visible. Unavailable status is distinct from zero runs. If history exceeds 25 playbook runs or 100 actions, totals include the history but status counts cover the latest runs and are marked accordingly.
+
+Receipts prefer custom action run names over action types, and retain collapsible summaries and result data. Other block results are read for the latest three playbook runs, up to 100 results each. Format/filter/decision/code datapaths are available through SOAR’s `block_results` API. Utility blocks appear only when their explicit run headers are included in SOAR’s playbook report; coverage varies by version. Output existence, a false condition, or overall playbook success is never treated as a block status. Use SOAR for missing statuses and complete history.
A submission is recorded before delivery. Use **Retry delivery** for a failed or uncertain request. Only the original requester can retry, and current permissions are checked. Retries preserve the original form, inputs, identity, connection settings, and source identifiers. Connection changes apply to new submissions; retain old credentials until pending requests have been resolved.
@@ -100,7 +108,7 @@ Delivery locks do not expire automatically. If a handler crashes while holding a
- Back up ActionStack KV collections and encrypted credentials with the Splunk deployment.
- Preserve form revisions, connection snapshots, unfinished submissions, and active delivery locks during retention cleanup.
- The catalog is paginated; submission lists show the latest 200 authorized records. KV scans are bounded at 50,000 records.
-- Delivery attempts are limited to 10 per user per minute. These limits are shared across members in a cluster. Lookup searches are limited to 60 and activity refreshes to 20 per user per minute.
+- Delivery attempts are limited to 10 per user per minute. These limits are shared across members in a cluster. Lookup searches are limited to 60, receipt activity refreshes to 20, and submission-list status reads to 60 per user per minute, with separate budgets.
- The app does not run a background retry or retention service. Prune old rate-limit records through your administration process, retaining at least the last 24 hours.
- Validate role isolation, credential access, delivery/retry behavior, and, for clusters, member failover in your deployment.
diff --git a/README.md b/README.md
index d9f53e9..4e9f46d 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@ Build forms in Splunk that submit events to Splunk SOAR.
- Form builder with conditional fields, validation, lookup inputs, multiple-value inputs, and collapsible sections.
- Drafts, publishing, version history, cloning, and recoverable form deletion.
- Configurable SOAR labels, tags, CEF mappings, and approval requirements handled by your playbooks.
-- Submission history, delivery retries, and playbook/action status with result summaries and data.
+- Paginated submission history with playbook/action status counts, delivery retries, and receipts with custom action names, reported block results, summaries, and data.
- Light, dark, and system themes.
## Installation
@@ -23,7 +23,7 @@ Download the app from [Splunkbase](https://splunkbase.splunk.com/app/9812):
- **Standalone search head:** install the app directly through Splunk Web.
- **Search head cluster:** deploy the app through the SHC deployer.
-Open ActionStack as a Splunk administrator. The setup wizard creates the first workspace and configures the SOAR connection. Create a form, select an existing SOAR label, and publish it. Enable **Allow SOAR automation on delivery** when the form should trigger active playbooks for that label.
+Open ActionStack as a Splunk administrator. The setup wizard creates the first workspace and configures the SOAR connection. Create a form, select an existing SOAR label, and publish it. **Allow SOAR automation on delivery** starts enabled for new forms; turn it off for intake-only forms. Existing forms keep their setting.
See [Deployment](DEPLOYMENT.md) for configuration and permissions, and the [SOAR event contract](SOAR_EVENT_CONTRACT.md) for submitted fields.
diff --git a/SOAR_EVENT_CONTRACT.md b/SOAR_EVENT_CONTRACT.md
index 495934c..971a794 100644
--- a/SOAR_EVENT_CONTRACT.md
+++ b/SOAR_EVENT_CONTRACT.md
@@ -62,7 +62,7 @@ When approval is unnecessary, the policy is `none`. These fields express a requi
4. Create the artifact with the container ID and the form's automation setting.
5. Save both IDs and mark delivery complete.
-Automatic playbooks are selected by the container label. Enable **Allow SOAR automation on delivery**, publish the form, and configure an active playbook for that label. ActionStack reads playbook status but does not explicitly start playbooks through the run API.
+Automatic playbooks are selected by the container label. **Allow SOAR automation on delivery** starts enabled for new forms. Publish the form with this enabled, and configure an active playbook for that label. ActionStack reads playbook status but does not explicitly start playbooks through the run API.
Source identifiers are stable across delivery retries:
diff --git a/frontend/src/AutomationActivity.tsx b/frontend/src/AutomationActivity.tsx
index 413cec0..0253006 100644
--- a/frontend/src/AutomationActivity.tsx
+++ b/frontend/src/AutomationActivity.tsx
@@ -1,5 +1,6 @@
import { Fragment, useEffect, useState } from "react";
import { RefreshCw } from "lucide-react";
+import { RunCounts } from "./RunCounts";
import { api } from "./api";
import type { Activity, RunGroup } from "./types";
@@ -8,7 +9,13 @@ function Runs({ title, group }: { title: string; group: RunGroup }) {
{title}
- {group.total !== null ? ` (${group.total})` : ""}
+ {group.counts ? (
+
+ ) : group.total !== null ? (
+ ` (${group.total})`
+ ) : (
+ ""
+ )}
{group.error ? (
@@ -24,7 +31,10 @@ function Runs({ title, group }: { title: string; group: RunGroup }) {
{run.name}
- Run #{run.id}
+ {run.block_type || `Run #${run.id}`}
+ {run.action && run.action !== run.name
+ ? ` · ${run.action}`
+ : ""}
{run.playbook_run_id
? ` · Playbook run #${run.playbook_run_id}`
: ""}
@@ -71,6 +81,7 @@ function Runs({ title, group }: { title: string; group: RunGroup }) {
))}
+ {group.notice &&
{group.notice}
}
{group.summary_error && (
{group.summary_error}
@@ -84,8 +95,8 @@ function Runs({ title, group }: { title: string; group: RunGroup }) {
)}
{group.truncated && (
- Showing the latest {group.items.length} of {group.total}. Open
- SOAR for the complete history.
+ Showing a limited set of recent results. Open SOAR for the
+ complete history.
)}
>
@@ -185,6 +196,7 @@ export function AutomationActivity({
)}
+ {data.blocks &&
}
Last checked {new Date(data.checked_at).toLocaleTimeString()} ·
Refreshes every 30 seconds while this receipt is visible.
diff --git a/frontend/src/Disclosure.tsx b/frontend/src/Disclosure.tsx
new file mode 100644
index 0000000..d4d9d33
--- /dev/null
+++ b/frontend/src/Disclosure.tsx
@@ -0,0 +1,27 @@
+import { useState, type ReactNode } from "react";
+import { ChevronDown } from "lucide-react";
+
+export function Disclosure({
+ title,
+ children,
+ initiallyOpen = false,
+}: {
+ title: string;
+ children: ReactNode;
+ initiallyOpen?: boolean;
+}) {
+ const [open, setOpen] = useState(initiallyOpen);
+ return (
+ setOpen(e.currentTarget.open)}
+ >
+
+ {title}
+
+
+ {children}
+
+ );
+}
diff --git a/frontend/src/FieldValidation.tsx b/frontend/src/FieldValidation.tsx
index 9b5c8ce..7696008 100644
--- a/frontend/src/FieldValidation.tsx
+++ b/frontend/src/FieldValidation.tsx
@@ -39,10 +39,9 @@ export function FieldValidation({
validation: rules.map((r, n) => (n === i ? { ...r, ...patch } : r)),
});
}
- if (field.type === "section") return null;
+ if (["section", "static_text"].includes(field.type)) return null;
return (
- Validation
Optional checks for this field.{" "}
{["text_list", "lookup_multi", "multiselect"].includes(field.type)
diff --git a/frontend/src/FormIcons.tsx b/frontend/src/FormIcons.tsx
index 99c6f1d..a98f49b 100644
--- a/frontend/src/FormIcons.tsx
+++ b/frontend/src/FormIcons.tsx
@@ -1,5 +1,8 @@
import {
FileText,
+ createLucideIcon,
+ Server,
+ UserRound,
Globe,
Search,
ShieldCheck,
@@ -7,7 +10,16 @@ import {
Zap,
} from "lucide-react";
+const ScanPulse = createLucideIcon("ScanPulse", [
+ ["circle", { cx: "12", cy: "12", r: "8", key: "reticle" }],
+ ["path", { d: "M12 2v3M12 19v3M2 12h3M19 12h3", key: "crosshairs" }],
+ ["path", { d: "M6 12h2l2-4 3 8 2-4h3", key: "pulse" }],
+]);
+
export const formIcons = {
+ scan: { label: "Scan", Icon: ScanPulse },
+ server: { label: "Server", Icon: Server },
+ user: { label: "User", Icon: UserRound },
shield: { label: "Shield", Icon: ShieldCheck },
workflow: { label: "Workflow", Icon: Workflow },
search: { label: "Search", Icon: Search },
diff --git a/frontend/src/LookupField.tsx b/frontend/src/LookupField.tsx
index 839880e..c1daa3b 100644
--- a/frontend/src/LookupField.tsx
+++ b/frontend/src/LookupField.tsx
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { ValueChips } from "./MultiInput";
import { api } from "./api";
import { lookupSearch } from "./lookup-search.js";
+import { mergeValues } from "./multi-values.js";
import type { Field, Form, LookupConfig } from "./types";
export const defaultLookup: LookupConfig = {
search: "| inputlookup identity_lookup_expanded | fields identity",
@@ -23,17 +24,18 @@ export function LookupSettings({
onChange({ lookup: { ...c, ...p } });
return (
-
Lookup source
Search
@@ -124,14 +126,80 @@ export function LookupField({
[more, setMore] = useState(false),
[error, setError] = useState(""),
[searching, setSearching] = useState(false),
+ [adding, setAdding] = useState(false),
[open, setOpen] = useState(false),
[active, setActive] = useState(-1);
const input = useRef(null),
+ results = useRef(null),
+ batchVersion = useRef(0),
+ currentValues = useRef(values),
skipValueSync = useRef(false),
selectedLabels = useRef>({}),
queue = useRef | null>(null);
const c = field.lookup || defaultLookup,
id = "input-" + field.key;
+ currentValues.current = values;
+ const fetchOptions = (t: string | string[]) =>
+ api<{ options: { value: string; label: string }[]; more: boolean }>(
+ preview ? "/admin/lookups/preview" : "/lookups/options",
+ preview
+ ? { config: c, term: t }
+ : {
+ form_id: form.id,
+ form_version: form.version,
+ field: field.key,
+ term: t,
+ },
+ );
+ async function addValues(raw = term) {
+ queue.current?.set("");
+ setSearching(false);
+ setOpen(false);
+ const version = ++batchVersion.current;
+ try {
+ const requested = mergeValues(values, raw, true).filter(
+ (v) =>
+ !values.some(
+ (selected) => selected.toLowerCase() === v.toLowerCase(),
+ ),
+ );
+ if (requested.length) {
+ setAdding(true);
+ const result = await fetchOptions(requested);
+ if (version !== batchVersion.current) return;
+ const found = new Map(
+ result.options.map((o) => [o.value.toLowerCase(), o]),
+ );
+ const missing = requested.filter((v) => !found.has(v.toLowerCase()));
+ if (missing.length)
+ throw new Error(
+ "Not found in lookup: " +
+ missing.join(", ") +
+ ". Nothing was added. Use lookup values or select search results.",
+ );
+ const next = mergeValues(
+ currentValues.current,
+ requested.map((v) => found.get(v.toLowerCase())!.value).join("\n"),
+ true,
+ );
+ if (next.length > 25) throw new Error("You can add up to 25 items.");
+ result.options.forEach((o) => {
+ selectedLabels.current[o.value] = o.label;
+ });
+ onChange(next);
+ }
+ setTerm("");
+ setError("");
+ } catch (e) {
+ if (version === batchVersion.current) setError((e as Error).message);
+ } finally {
+ if (version === batchVersion.current) setAdding(false);
+ }
+ }
+ useEffect(() => {
+ if (open && active >= 0)
+ results.current?.children[active]?.scrollIntoView({ block: "nearest" });
+ }, [active, open]);
useEffect(() => {
if (skipValueSync.current) {
skipValueSync.current = false;
@@ -142,25 +210,18 @@ export function LookupField({
}, [value, multiple]);
useEffect(() => {
input.current?.setCustomValidity(
- term && (multiple || !value)
- ? "Select a value from the lookup results."
- : "",
+ adding
+ ? "Checking lookup values…"
+ : term && (multiple || !value)
+ ? multiple
+ ? "Press Enter or Add values, or select lookup results."
+ : "Select a value from the lookup results."
+ : "",
);
- }, [term, value]);
+ }, [term, value, multiple, adding]);
useEffect(() => {
const q = lookupSearch(
- (t) =>
- api<{ options: { value: string; label: string }[]; more: boolean }>(
- preview ? "/admin/lookups/preview" : "/lookups/options",
- preview
- ? { config: c, term: t }
- : {
- form_id: form.id,
- form_version: form.version,
- field: field.key,
- term: t,
- },
- ),
+ fetchOptions,
(result, message) => {
setOptions(result?.options || []);
setMore(!!result?.more);
@@ -172,12 +233,21 @@ export function LookupField({
c.debounce_ms,
);
queue.current = q;
- return () => q.dispose();
+ setAdding(false);
+ return () => {
+ q.dispose();
+ batchVersion.current++;
+ };
}, [form.id, form.version, field.key, JSON.stringify(c), preview]);
const select = (option: { value: string; label: string }) => {
const v = option.value;
+ if (
+ multiple &&
+ (values.some((selected) => selected.toLowerCase() === v.toLowerCase()) ||
+ values.length >= 25)
+ )
+ return;
queue.current?.set("");
- if (multiple && (values.includes(v) || values.length >= 25)) return;
selectedLabels.current[v] = option.label;
setTerm(multiple ? "" : option.label);
onChange(multiple ? [...values, v] : v);
@@ -194,56 +264,94 @@ export function LookupField({
onChange={onChange}
/>
)}
- 0}
- aria-controls={id + "-results"}
- aria-activedescendant={
- active >= 0 ? id + "-option-" + active : undefined
- }
- autoComplete="off"
- required={field.required && (!multiple || !values.length)}
- placeholder={
- field.placeholder || "Type " + c.min_chars + " characters to search…"
- }
- value={term}
- maxLength={200}
- onFocus={() => setOpen(true)}
- onBlur={() => setOpen(false)}
- onChange={(e) => {
- const t = e.target.value;
- if (!multiple && value) {
- skipValueSync.current = true;
- onChange("");
- }
- setTerm(t);
- setOpen(true);
- setSearching(t.length >= c.min_chars);
- queue.current?.set(t);
- }}
- onKeyDown={(e) => {
- if (e.key === "Escape") setOpen(false);
- if (e.key === "ArrowDown" && options.length) {
- e.preventDefault();
- setOpen(true);
- setActive((n) => Math.min(n + 1, options.length - 1));
+
+ 0}
+ aria-controls={id + "-results"}
+ aria-activedescendant={
+ open && active >= 0 ? id + "-option-" + active : undefined
}
- if (e.key === "ArrowUp" && options.length) {
- e.preventDefault();
- setActive((n) => Math.max(n - 1, 0));
+ autoComplete="off"
+ required={field.required && (!multiple || !values.length)}
+ placeholder={
+ field.placeholder ||
+ "Type " + c.min_chars + " characters to search…"
}
- if (e.key === "Enter" && term) e.preventDefault();
- if (e.key === "Enter" && open && active >= 0 && options[active]) {
+ value={term}
+ maxLength={multiple ? 5025 : 200}
+ aria-busy={adding}
+ onFocus={() => setOpen(true)}
+ onBlur={() => setOpen(false)}
+ onChange={(e) => {
+ // Editing cancels the pending batch; late replies must not add chips.
+ batchVersion.current++;
+ setAdding(false);
+ const t = e.target.value;
+ if (!multiple && value) {
+ skipValueSync.current = true;
+ onChange("");
+ }
+ setTerm(t);
+ setOpen(true);
+ const query = multiple && /[,;\r\n]/.test(t) ? "" : t;
+ setSearching(query.length >= c.min_chars);
+ queue.current?.set(query);
+ }}
+ onPaste={(e) => {
+ const pasted = e.clipboardData.getData("text");
+ if (!multiple || !/[,;\r\n]/.test(pasted)) return;
e.preventDefault();
- select(options[active]);
- }
- }}
- />
+ const el = e.currentTarget;
+ const raw =
+ term.slice(0, el.selectionStart ?? term.length) +
+ pasted +
+ term.slice(el.selectionEnd ?? term.length);
+ setTerm(raw);
+ void addValues(raw);
+ }}
+ onKeyDown={(e) => {
+ if (e.nativeEvent.isComposing) return;
+ if (adding) {
+ if (e.key === "Enter") e.preventDefault();
+ return;
+ }
+ if (e.key === "Escape") setOpen(false);
+ if (e.key === "ArrowDown" && options.length) {
+ e.preventDefault();
+ setOpen(true);
+ setActive((n) => Math.min(n + 1, options.length - 1));
+ }
+ if (e.key === "ArrowUp" && options.length) {
+ e.preventDefault();
+ setActive((n) => Math.max(n - 1, 0));
+ }
+ if (e.key === "Enter" && term) e.preventDefault();
+ if (e.key === "Enter" && open && active >= 0 && options[active]) {
+ e.preventDefault();
+ select(options[active]);
+ } else if (e.key === "Enter" && multiple && term.trim()) {
+ void addValues();
+ }
+ }}
+ />
+ {multiple && term.trim() && (
+ void addValues()}
+ >
+ {adding ? "Checking values…" : "Add values"}
+
+ )}
+
{open && options.length > 0 && (
= 25)
+ multiple &&
+ (values.some(
+ (selected) =>
+ selected.toLowerCase() === o.value.toLowerCase(),
+ ) ||
+ values.length >= 25)
}
onMouseDown={(e) => e.preventDefault()}
onClick={() => select(o)}
@@ -267,19 +380,24 @@ export function LookupField({
)}
{error ||
- (searching
- ? "Searching…"
- : multiple && !term
- ? values.length + "/25 selected · Search to add another."
- : !multiple && value
- ? "Selected from lookup"
- : term.length < c.min_chars
- ? "Enter at least " + c.min_chars + " characters."
- : more
- ? "More matches available. Keep typing to narrow the list."
- : options.length
- ? options.length + " matches"
- : "No matches found.")}
+ (adding
+ ? "Checking lookup values…"
+ : searching
+ ? "Searching…"
+ : multiple && !term
+ ? values.length +
+ "/25 selected · Search or paste comma-separated values."
+ : !multiple && value
+ ? "Selected from lookup"
+ : multiple && /[,;\r\n]/.test(term)
+ ? "Press Enter or Add values to check this list."
+ : term.length < c.min_chars
+ ? "Enter at least " + c.min_chars + " characters."
+ : more
+ ? "More matches available. Keep typing to narrow the list."
+ : options.length
+ ? options.length + " matches"
+ : "No matches found.")}
);
diff --git a/frontend/src/MultiInput.tsx b/frontend/src/MultiInput.tsx
index 7bf94f2..04d8f8a 100644
--- a/frontend/src/MultiInput.tsx
+++ b/frontend/src/MultiInput.tsx
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { X } from "lucide-react";
import type { Field } from "./types";
+import { mergeValues } from "./multi-values.js";
export function ValueChips({
values,
labels = {},
@@ -44,20 +45,14 @@ export function MultiInput({
text.trim() ? "Press Enter or Add to include this value." : error,
);
}, [text, error]);
- function add() {
- const next = text.trim();
- if (!next) return;
- if (value.includes(next)) {
- setError("This item is already added.");
- return;
+ function add(raw = text) {
+ try {
+ onChange(mergeValues(value, raw));
+ setText("");
+ setError("");
+ } catch (e) {
+ setError((e as Error).message);
}
- if (value.length >= 25) {
- setError("You can add up to 25 items.");
- return;
- }
- onChange([...value, next]);
- setText("");
- setError("");
}
return (
@@ -75,7 +70,7 @@ export function MultiInput({
aria-describedby={"input-" + field.key + "-help"}
required={field.required && !value.length}
value={text}
- maxLength={200}
+ maxLength={5025}
placeholder={field.placeholder || "Type a value and press Enter"}
onChange={(e) => {
setText(e.target.value);
@@ -87,18 +82,31 @@ export function MultiInput({
add();
}
}}
+ onPaste={(e) => {
+ const pasted = e.clipboardData.getData("text");
+ if (!/[,;\r\n]/.test(pasted)) return;
+ e.preventDefault();
+ const el = e.currentTarget;
+ const raw =
+ text.slice(0, el.selectionStart ?? text.length) +
+ pasted +
+ text.slice(el.selectionEnd ?? text.length);
+ setText(raw);
+ add(raw);
+ }}
/>
add()}
>
Add
- {error || `${value.length}/25 items · Press Enter to add each value.`}
+ {error ||
+ `${value.length}/25 items · Press Enter to add. Paste a comma-separated list to add several.`}
);
diff --git a/frontend/src/RunCounts.tsx b/frontend/src/RunCounts.tsx
new file mode 100644
index 0000000..beca6ab
--- /dev/null
+++ b/frontend/src/RunCounts.tsx
@@ -0,0 +1,61 @@
+import type { RunGroup } from "./types";
+
+export type CountGroup = Pick<
+ RunGroup,
+ "counts" | "total" | "truncated" | "error"
+>;
+
+export function RunCounts({
+ group,
+ label,
+}: {
+ group?: CountGroup;
+ label: string;
+}) {
+ if (!group) return Checking… ;
+ if (group.error || group.total === null)
+ return (
+
+ Unavailable
+
+ );
+ return (
+
+
+ {group.total} total
+
+ {Object.entries(group.counts || {}).map(
+ ([status, count]) =>
+ count > 0 && (
+
+ {status === "success"
+ ? "✓"
+ : status === "failed"
+ ? "✕"
+ : status === "running"
+ ? "↻"
+ : status === "pending"
+ ? "◷"
+ : status === "cancelled"
+ ? "−"
+ : "?"}{" "}
+ {count}
+ {status}
+
+ ),
+ )}
+ {group.truncated && (
+
+ Recent runs
+
+ )}
+
+ );
+}
diff --git a/frontend/src/StaticText.tsx b/frontend/src/StaticText.tsx
new file mode 100644
index 0000000..d8e5b95
--- /dev/null
+++ b/frontend/src/StaticText.tsx
@@ -0,0 +1,20 @@
+import { Info, TriangleAlert } from "lucide-react";
+import type { Field } from "./types";
+
+/** Plain text only: form authors cannot inject HTML into a submission page. */
+export function StaticText({ field }: { field: Field }) {
+ const tone = field.tone || "text";
+ return (
+
+ {tone === "info" &&
}
+ {tone === "warning" &&
}
+
+
{field.label}
+ {field.help &&
{field.help}
}
+
+
+ );
+}
diff --git a/frontend/src/lookup-search.js b/frontend/src/lookup-search.js
index c39b2de..f0cafa2 100644
--- a/frontend/src/lookup-search.js
+++ b/frontend/src/lookup-search.js
@@ -10,6 +10,9 @@ export function lookupSearch(search, receive, minimum = 3, delay = 50) {
disposed = false;
/** @type {ReturnType|undefined} */ let timer;
/** @type {{term:string,version:number}|null} */ let ready = null;
+ // Per-field, per-mount cache only. Submission always rechecks live lookup data.
+ /** @type {Map} */ const cache =
+ new Map();
async function pump() {
if (running || !ready || disposed) return;
const job = ready;
@@ -17,6 +20,11 @@ export function lookupSearch(search, receive, minimum = 3, delay = 50) {
running = true;
try {
const result = await search(job.term);
+ if (!disposed) {
+ cache.delete(job.term);
+ cache.set(job.term, { result, expires: Date.now() + 30000 });
+ if (cache.size > 30) cache.delete(cache.keys().next().value ?? "");
+ }
if (!disposed && job.version === version) receive(result, "");
} catch (e) {
if (!disposed && job.version === version)
@@ -33,6 +41,11 @@ export function lookupSearch(search, receive, minimum = 3, delay = 50) {
ready = null;
receive(null, "");
if (term.length < minimum) return;
+ const cached = cache.get(term);
+ if (cached && cached.expires > Date.now()) {
+ receive(cached.result, "");
+ return;
+ }
const current = version;
timer = setTimeout(() => {
ready = { term, version: current };
@@ -44,6 +57,7 @@ export function lookupSearch(search, receive, minimum = 3, delay = 50) {
version++;
clearTimeout(timer);
ready = null;
+ cache.clear();
},
};
}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 377c2c0..f8ea266 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -1,3 +1,4 @@
+import { StaticText } from "./StaticText";
import {
randomId,
cloneDefinition,
@@ -66,6 +67,9 @@ import { FormAppearance, ThemePicker } from "./Appearance";
import { FormSections } from "./FormSections";
import { cloneForm } from "./clone-form.js";
import { ApprovalRules } from "./ApprovalRules";
+import { Disclosure } from "./Disclosure";
+import { RunCounts } from "./RunCounts";
+import { useSubmissionActivity } from "./submission-activity";
import { AutomationActivity } from "./AutomationActivity";
import { approvalRequired } from "./approval.js";
import { version as appVersion } from "../../package.json";
@@ -94,6 +98,7 @@ const fieldTypes = [
["date", "Date"],
["datetime", "Date & time"],
["section", "Section"],
+ ["static_text", "Static text"],
];
function formatDate(date: string) {
return new Intl.DateTimeFormat(undefined, {
@@ -276,6 +281,7 @@ function FormFields({
const renderField = (f: Field) => {
if (f.show_when && values[f.show_when.field] !== f.show_when.equals)
return null;
+ if (f.type === "static_text") return ;
if (f.type === "section")
return (
@@ -453,6 +459,7 @@ function defaults(form: Form) {
const v: Record = {};
for (const f of form.fields)
if (
+ !["section", "static_text"].includes(f.type) &&
(f.default !== undefined || f.type === "checkbox") &&
(!f.show_when || v[f.show_when.field] === f.show_when.equals)
)
@@ -492,12 +499,29 @@ function App() {
[workspaceId, setWorkspaceId] = useState("security"),
[allSubmissions, setSubmissions] = useState([]),
[page, setPage] = useState("catalog"),
+ [submissionPage, setSubmissionPage] = useState(1),
[selected, setSelected] = useState
);
return (
-
+
Showing up to 200 recent submissions you have permission to
- view.
+ view. Run counts refresh every 30 seconds for this page.
@@ -944,11 +980,13 @@ function App() {
Submitted
Submitted by
SOAR event
+
Playbooks
+
Actions
- {submissions.map((s) => (
+ {pageSubmissions.map((s) => (
setDetail(s)}>
{s.container_id ? "#" + s.container_id : "—"}
+
+ {s.container_id ? (
+
+ ) : (
+ "—"
+ )}
+
+
+ {s.container_id ? (
+
+ ) : (
+ "—"
+ )}
+
@@ -976,6 +1034,27 @@ function App() {
))}
+
+
+ setSubmissionPage(currentSubmissionPage - 1)
+ }
+ >
+ Previous
+
+
+ Page {currentSubmissionPage} of {submissionPages}
+
+ = submissionPages}
+ onClick={() =>
+ setSubmissionPage(currentSubmissionPage + 1)
+ }
+ >
+ Next
+
+
)}
>
@@ -1249,7 +1328,7 @@ function RequestForm({
{form.fields
.filter(
(f) =>
- f.type !== "section" &&
+ !["section", "static_text"].includes(f.type) &&
values[f.key] !== undefined &&
(!f.show_when ||
values[f.show_when.field] === f.show_when.equals),
@@ -1562,7 +1641,7 @@ function Builder({
tags: ["source:splunk_actionstack"],
severity: "low",
sensitivity: "amber",
- run_automation: false,
+ run_automation: true,
title_prefix: "Form submission",
},
access: workspace.default_access
@@ -1659,6 +1738,12 @@ function Builder({
: {}),
label: fieldTypes.find((t) => t[0] === type)?.[1] || "Field",
required: false,
+ ...(type === "static_text"
+ ? {
+ help: "Add a description or guidance for your team.",
+ tone: "text" as const,
+ }
+ : {}),
...(["select", "radio", "multiselect"].includes(type)
? {
options: [
@@ -1926,37 +2011,54 @@ function Builder({
Add a field
The building blocks of your form.
-
- {fieldTypes.map(([type, label]) => (
-
add(type)}>
-
- {
- (
- {
- lookup: "⌕",
- lookup_multi: "⌕+",
- text_list: "T+",
- text: "T",
- textarea: "☰",
- select: "⌄",
- number: "#",
- email: "@",
- url: "↗",
- date: "▦",
- datetime: "◷",
- section: "▬",
- checkbox: "☑",
- radio: "◉",
- multiselect: "☷",
- } as Record
- )[type]
- }
-
- {label}
-
-
- ))}
-
+ {[
+ ["Text & numbers", ["text", "textarea", "text_list", "number"]],
+ ["Lookup inputs", ["lookup", "lookup_multi"]],
+ ["Choices", ["select", "multiselect", "radio", "checkbox"]],
+ ["Dates & contact", ["email", "url", "date", "datetime"]],
+ ["Layout & messages", ["section", "static_text"]],
+ ].map(([heading, types]) => (
+
+
+ {fieldTypes
+ .filter(([type]) => types.includes(type))
+ .map(([type, label]) => (
+
add(type)}>
+
+ {
+ (
+ {
+ lookup: "⌕",
+ lookup_multi: "⌕+",
+ text_list: "T+",
+ text: "T",
+ textarea: "☰",
+ select: "⌄",
+ number: "#",
+ email: "@",
+ url: "↗",
+ date: "▦",
+ datetime: "◷",
+ section: "▬",
+ static_text: "¶",
+ checkbox: "☑",
+ radio: "◉",
+ multiselect: "☷",
+ } as Record
+ )[type]
+ }
+
+ {label}
+
+
+ ))}
+
+
+ ))}
Drag fields to reorder, or use the arrow controls.
@@ -2061,7 +2163,8 @@ function Builder({
- {f.type !== "section" && (
+ {f.type === "static_text" && }
+ {!["section", "static_text"].includes(f.type) && (
{field && (
- <>
-
- Label
- fieldUpdate({ label: e.target.value })}
- />
-
-
- Field key
- fieldUpdate({ key: e.target.value })}
- />
- Stable identifier used in SOAR.
-
-
- Field type
-
- fieldUpdate({
- type: e.target.value,
- default: undefined,
- collapsed:
- e.target.value === "section" ? false : undefined,
- validation:
- e.target.value === "section" ? [] : field.validation,
- lookup: ["lookup", "lookup_multi"].includes(
- e.target.value,
- )
- ? field.lookup || { ...defaultLookup }
- : undefined,
- ...(["select", "radio", "multiselect"].includes(
- e.target.value,
- ) && !field.options
- ? {
- options: [
- { value: "option_1", label: "Option 1" },
- ],
- }
- : {}),
- })
- }
- >
- {fieldTypes.map(([v, l]) => (
-
- {l}
-
- ))}
-
-
- {field.type === "section" && (
-
-
- Collapsed by default
-
- Groups the fields below until the next section. Users
- can expand it; validation still applies.
-
-
-
- fieldUpdate({ collapsed: e.target.checked })
- }
- />
-
- )}
- {["lookup", "lookup_multi"].includes(field.type) && (
-
- )}
- {field.type !== "section" && (
-
-
- Required field
- A response is needed to submit.
-
+
+
+
+ {field.type === "static_text" ? "Heading" : "Label"}
- fieldUpdate({ required: e.target.checked })
- }
+ value={field.label}
+ onChange={(e) => fieldUpdate({ label: e.target.value })}
/>
- )}
- {field.required_when?.map((condition, i) => (
-
-
- Required when {condition.field} ={" "}
- {String(condition.equals)}
-
-
- fieldUpdate({
- required_when: field.required_when?.filter(
- (_, n) => n !== i,
- ),
- })
- }
- >
- Remove condition
-
-
- ))}
-
- Placeholder
-
- fieldUpdate({ placeholder: e.target.value })
- }
- />
-
-
- Helper text
-
- {["select", "radio", "multiselect"].includes(field.type) && (
- Options
- o.value + " | " + o.label)
- .join("\n")}
- onCommit={(value) =>
- fieldUpdate({
- options: value
- .split("\n")
- .filter((line) => line.trim())
- .map((line) => {
- const [value, ...label] = line.split("|");
- return {
- value: value.trim(),
- label: label.join("|").trim() || value.trim(),
- };
- }),
- })
- }
+ value={field.key}
+ onChange={(e) => fieldUpdate({ key: e.target.value })}
/>
- One value | label per line.
+
+ {field.type === "static_text"
+ ? "Identifier for this text item. It is not sent to SOAR."
+ : "Stable identifier used in SOAR."}
+
- )}
- {["text", "textarea", "email", "url"].includes(field.type) && (
- Maximum length
-
fieldUpdate({
- max_length: e.target.value
- ? Number(e.target.value)
+ type: e.target.value,
+ default: undefined,
+ collapsed:
+ e.target.value === "section" ? false : undefined,
+ validation: ["section", "static_text"].includes(
+ e.target.value,
+ )
+ ? []
+ : field.validation,
+ tone:
+ e.target.value === "static_text"
+ ? "text"
+ : undefined,
+ ...(e.target.value === "static_text"
+ ? {
+ required: false,
+ required_when: undefined,
+ cef_key: undefined,
+ options: undefined,
+ placeholder: undefined,
+ min: undefined,
+ max: undefined,
+ min_length: undefined,
+ max_length: undefined,
+ }
+ : {}),
+ lookup: ["lookup", "lookup_multi"].includes(
+ e.target.value,
+ )
+ ? field.lookup || { ...defaultLookup }
: undefined,
+ ...(["select", "radio", "multiselect"].includes(
+ e.target.value,
+ ) && !field.options
+ ? {
+ options: [
+ { value: "option_1", label: "Option 1" },
+ ],
+ }
+ : {}),
})
}
+ >
+ {fieldTypes.map(([v, l]) => (
+
+ {l}
+
+ ))}
+
+
+
+
+ {field.type === "static_text" && (
+
+ Style
+
+ fieldUpdate({ tone: e.target.value as Field["tone"] })
+ }
+ >
+ Plain text
+ Information
+ Warning
+
+
+ Display-only content. Use Show condition below for a
+ conditional warning.
+
+
+ )}
+ {field.type === "section" && (
+
+
+ Collapsed by default
+
+ Groups the fields below until the next section. Users
+ can expand it; validation still applies.
+
+
+
+ fieldUpdate({ collapsed: e.target.checked })
+ }
+ />
+
+ )}
+
+ {!["section", "static_text"].includes(field.type) && (
+
+
+ Required field
+ A response is needed to submit.
+
+
+ fieldUpdate({ required: e.target.checked })
+ }
+ />
+
+ )}
+ {field.required_when?.map((condition, i) => (
+
+
+ Required when {condition.field} ={" "}
+ {String(condition.equals)}
+
+
+ fieldUpdate({
+ required_when: field.required_when?.filter(
+ (_, n) => n !== i,
+ ),
+ })
+ }
+ >
+ Remove condition
+
+
+ ))}
+ {field.type !== "static_text" && (
+
+ Placeholder
+
+ fieldUpdate({ placeholder: e.target.value })
+ }
+ />
+
+ )}
+
+ {field.type === "static_text" ? "Message" : "Helper text"}
+
+ {["select", "radio", "multiselect"].includes(field.type) && (
+
+ Options
+ o.value + " | " + o.label)
+ .join("\n")}
+ onCommit={(value) =>
+ fieldUpdate({
+ options: value
+ .split("\n")
+ .filter((line) => line.trim())
+ .map((line) => {
+ const [value, ...label] = line.split("|");
+ return {
+ value: value.trim(),
+ label: label.join("|").trim() || value.trim(),
+ };
+ }),
+ })
+ }
+ />
+ One value | label per line.
+
+ )}
+ {["text", "textarea", "email", "url"].includes(
+ field.type,
+ ) && (
+
+ Maximum length
+
+ fieldUpdate({
+ max_length: e.target.value
+ ? Number(e.target.value)
+ : undefined,
+ })
+ }
+ />
+
+ )}
+ {field.type === "number" && (
+
+ {(["min", "max"] as const).map((k) => (
+
+ {k === "min" ? "Minimum" : "Maximum"}
+
+ fieldUpdate({
+ [k]: e.target.value
+ ? Number(e.target.value)
+ : undefined,
+ })
+ }
+ />
+
+ ))}
+
+ )}
+ {![
+ "multiselect",
+ "section",
+ "static_text",
+ "text_list",
+ "lookup_multi",
+ ].includes(field.type) && (
+
+ Default value
+
+ fieldUpdate({
+ default:
+ e.target.value === ""
+ ? undefined
+ : field.type === "number"
+ ? Number(e.target.value)
+ : field.type === "checkbox"
+ ? e.target.value === "true"
+ : e.target.value,
+ })
+ }
+ />
+
+ {field.type === "checkbox"
+ ? "Use true or false."
+ : "Optional initial value."}
+
+
+ )}
+
+ {["lookup", "lookup_multi"].includes(field.type) && (
+
+
+
)}
- {field.type === "number" && (
-
- {(["min", "max"] as const).map((k) => (
-
- {k === "min" ? "Minimum" : "Maximum"}
-
- fieldUpdate({
- [k]: e.target.value
- ? Number(e.target.value)
- : undefined,
- })
- }
- />
-
- ))}
-
+ {!["section", "static_text"].includes(field.type) && (
+
+
+
)}
- {![
- "multiselect",
- "section",
- "text_list",
- "lookup_multi",
- ].includes(field.type) && (
+
- Default value
-
fieldUpdate({
- default:
- e.target.value === ""
- ? undefined
- : field.type === "number"
- ? Number(e.target.value)
- : field.type === "checkbox"
- ? e.target.value === "true"
- : e.target.value,
+ show_when: e.target.value
+ ? { field: e.target.value, equals: "" }
+ : undefined,
})
}
- />
-
- {field.type === "checkbox"
- ? "Use true or false."
- : "Optional initial value."}
-
-
- )}
-
-
-
- Show condition
-
- fieldUpdate({
- show_when: e.target.value
- ? { field: e.target.value, equals: "" }
- : undefined,
- })
- }
- >
- Always show
- {editor.fields
- .slice(0, selected)
- .filter((f) => f.type !== "section")
- .map((f) => (
-
- {f.label}
-
- ))}
-
-
- {field.show_when && (
-
- Equals
- {
- const controller = editor.fields.find(
- (f) => f.key === field.show_when!.field,
- );
- fieldUpdate({
- show_when: {
- field: field.show_when!.field,
- equals:
- controller?.type === "checkbox"
- ? e.target.value === "true"
- : controller?.type === "number"
- ? Number(e.target.value)
- : e.target.value,
- },
- });
- }}
- />
+ >
+ Always show
+ {editor.fields
+ .slice(0, selected)
+ .filter(
+ (f) => !["section", "static_text"].includes(f.type),
+ )
+ .map((f) => (
+
+ {f.label}
+
+ ))}
+
+ {field.show_when && (
+
+ Equals
+ {
+ const controller = editor.fields.find(
+ (f) => f.key === field.show_when!.field,
+ );
+ fieldUpdate({
+ show_when: {
+ field: field.show_when!.field,
+ equals:
+ controller?.type === "checkbox"
+ ? e.target.value === "true"
+ : controller?.type === "number"
+ ? Number(e.target.value)
+ : e.target.value,
+ },
+ });
+ }}
+ />
+
+ )}
+
+ {!["static_text", "section"].includes(field.type) && (
+
+
+ Optional CEF mapping
+
+ fieldUpdate({ cef_key: e.target.value })
+ }
+ />
+
+
)}
-
- Optional CEF mapping
- fieldUpdate({ cef_key: e.target.value })}
- />
-
- >
+
)}
diff --git a/frontend/src/multi-values.js b/frontend/src/multi-values.js
new file mode 100644
index 0000000..a76f841
--- /dev/null
+++ b/frontend/src/multi-values.js
@@ -0,0 +1,23 @@
+/** Split pasted or typed lists, preserving order and removing duplicates.
+ * @param {string[]} existing
+ * @param {string} text
+ * @param {boolean} [ignoreCase]
+ */
+export function mergeValues(existing, text, ignoreCase = false) {
+ const added = text
+ .split(/[,;\r\n]+/)
+ .map((v) => v.trim())
+ .filter(Boolean);
+ if (added.some((v) => v.length > 200))
+ throw new Error("Each item must be 200 characters or fewer.");
+ const seen = new Set();
+ const values = [...existing, ...added].filter((v) => {
+ const key = ignoreCase ? v.toLowerCase() : v;
+ if (seen.has(key)) return false;
+ seen.add(key);
+ return true;
+ });
+ if (values.length > 25)
+ throw new Error("You can add up to 25 items. Nothing was added.");
+ return values;
+}
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index fb2342a..9806233 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -110,7 +110,7 @@
outline-offset: 1px;
}
.actionstack-app small {
- font-size: 11px;
+ font-size: 13px;
color: var(--muted);
}
.sidebar {
@@ -1226,7 +1226,7 @@
.actionstack-app table {
width: 100%;
border-collapse: collapse;
- font-size: 11px;
+ font-size: 13px;
}
.actionstack-app th {
color: #8993a7;
@@ -1253,7 +1253,7 @@
}
.actionstack-app td small {
display: block;
- font-size: 9px;
+ font-size: 13px;
margin-top: 4px;
color: #748097;
}
@@ -1274,7 +1274,7 @@
color: #baaccf;
border-radius: 5px;
padding: 4px 7px;
- font-size: 9px !important;
+ font-size: 12px !important;
font-weight: 400;
white-space: nowrap;
letter-spacing: 0;
@@ -1384,7 +1384,7 @@
gap: 15px;
border-bottom: 1px solid #292f3a;
padding: 12px 0;
- font-size: 11px;
+ font-size: 13px;
}
.summary-list > div:last-child {
border-bottom: 0;
@@ -1411,7 +1411,7 @@
background: #0d1119;
border: 1px solid #2b3442;
border-radius: 7px;
- font-size: 11px;
+ font-size: 13px;
color: #b4c2d6;
}
.text-button {
@@ -1516,7 +1516,7 @@
border-radius: 0;
padding: 12px 0;
color: #8895aa;
- font-size: 11px;
+ font-size: 13px;
}
.editor-tabs button.selected {
color: #c7b2e9;
@@ -1524,7 +1524,7 @@
}
.editor-grid {
display: grid;
- grid-template-columns: 180px minmax(250px, 1fr) 240px;
+ grid-template-columns: 180px minmax(250px, 1fr) 280px;
border: 1px solid #2e3340;
border-radius: 9px;
overflow: hidden;
@@ -1536,10 +1536,10 @@
}
.palette h3,
.properties h3 {
- font-size: 12px;
+ font-size: 14px;
}
.palette > p {
- font-size: 9px;
+ font-size: 13px;
color: #7d8a9f;
margin: 5px 0 20px;
}
@@ -1553,7 +1553,7 @@
background: #191e29;
padding: 10px 9px;
border-radius: 5px;
- font-size: 10px;
+ font-size: 13px;
text-align: left;
display: flex;
align-items: center;
@@ -1584,7 +1584,7 @@
color: #727f96;
}
.palette-note p {
- font-size: 9px;
+ font-size: 13px;
line-height: 1.7;
}
.palette-note svg {
@@ -1598,7 +1598,7 @@
padding: 17px 20px 26px;
}
.canvas-caption {
- font-size: 8px;
+ font-size: 13px;
letter-spacing: 1px;
color: #718097;
display: flex;
@@ -1635,7 +1635,7 @@
background: none !important;
border: 1px solid transparent !important;
padding: 4px 3px !important;
- font-size: 10px !important;
+ font-size: 13px !important;
color: #8592ab !important;
margin-bottom: 21px;
resize: vertical;
@@ -1663,7 +1663,7 @@
display: flex;
align-items: center;
gap: 4px;
- font-size: 10px;
+ font-size: 13px;
}
.canvas-field-label > svg {
color: #59637a;
@@ -1699,7 +1699,7 @@
border: 1px solid #2e3745;
border-radius: 5px;
padding: 10px;
- font-size: 9px;
+ font-size: 13px;
color: #606f89;
}
.canvas-placeholder.tall {
@@ -1709,7 +1709,7 @@
display: flex;
gap: 5px;
align-items: center;
- font-size: 8px !important;
+ font-size: 13px !important;
color: #ad93c9 !important;
margin-top: 7px;
}
@@ -1725,7 +1725,7 @@
border-radius: 5px;
background: none;
color: #ad95cc !important;
- font-size: 10px !important;
+ font-size: 13px !important;
}
.canvas-submit {
display: flex;
@@ -1735,7 +1735,7 @@
background: #bfa8e5;
border-radius: 5px;
color: #261c32;
- font-size: 10px;
+ font-size: 13px;
padding: 10px;
margin-top: 24px;
opacity: 0.75;
@@ -1751,19 +1751,19 @@
margin-bottom: 20px;
}
.properties .field {
- font-size: 10px;
+ font-size: 14px;
margin-bottom: 17px;
gap: 6px;
}
.properties input,
.properties textarea,
.properties select {
- font-size: 10px !important;
+ font-size: 14px !important;
padding: 8px !important;
min-height: 34px !important;
}
.properties small {
- font-size: 8px !important;
+ font-size: 13px !important;
line-height: 1.65;
}
.toggle-row {
@@ -1775,10 +1775,10 @@
border-top: 1px solid #2a313e;
border-bottom: 1px solid #2a313e;
margin: 8px 0 22px;
- font-size: 11px;
+ font-size: 13px;
}
.toggle-row b {
- font-size: 11px;
+ font-size: 13px;
font-weight: 500;
}
.toggle-row small {
@@ -1794,7 +1794,7 @@
padding: 12px 0;
}
.properties .toggle-row b {
- font-size: 10px;
+ font-size: 13px;
}
.property-divider {
height: 1px;
@@ -1959,7 +1959,7 @@
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.editor-grid {
- grid-template-columns: 145px minmax(210px, 1fr) 210px;
+ grid-template-columns: 160px minmax(210px, 1fr) 260px;
}
.canvas {
padding: 15px 12px;
@@ -2061,13 +2061,13 @@
display: none;
}
.editor-grid {
- grid-template-columns: 120px minmax(180px, 1fr) 190px;
+ grid-template-columns: 130px minmax(160px, 1fr) 220px;
}
.palette {
padding: 16px 9px;
}
.palette-grid button {
- font-size: 9px;
+ font-size: 13px;
padding: 8px 6px;
gap: 5px;
}
@@ -2084,7 +2084,7 @@
padding: 15px 9px;
}
.canvas-field-label {
- font-size: 9px;
+ font-size: 13px;
}
.field-controls {
gap: 0;
@@ -2273,7 +2273,7 @@
grid-template-columns: repeat(3, 1fr);
}
.palette-grid button {
- font-size: 10px;
+ font-size: 13px;
}
.palette-note {
display: none;
@@ -2288,15 +2288,15 @@
padding: 23px;
}
.properties .field {
- font-size: 12px;
+ font-size: 14px;
}
.properties input,
.properties select,
.properties textarea {
- font-size: 12px !important;
+ font-size: 14px !important;
}
.properties small {
- font-size: 10px !important;
+ font-size: 13px !important;
}
.editor-tabs {
gap: 14px;
@@ -2304,7 +2304,7 @@
overflow: auto;
}
.editor-tabs button {
- font-size: 10px;
+ font-size: 13px;
}
.builder-intro {
padding: 20px;
@@ -2541,11 +2541,8 @@
margin-top: 10px;
}
.actionstack-lookup-results {
- position: absolute;
- top: 54px;
- left: 0;
- right: 0;
- z-index: 15;
+ position: relative;
+ margin-top: 8px;
max-height: 250px;
overflow: auto;
padding: 6px;
@@ -3658,3 +3655,160 @@
-10px 10px 30px -10px #9b6ee54d, 12px 12px 32px -12px #48b7d14d,
0 12px 22px -12px #34436733;
}
+
+.actionstack-app .actionstack-static-text {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ margin: 0 0 21px;
+ color: var(--text);
+ overflow-wrap: anywhere;
+}
+.actionstack-app .actionstack-static-text > div {
+ min-width: 0;
+}
+.actionstack-app .actionstack-static-text > svg {
+ flex-shrink: 0;
+ margin-top: 2px;
+}
+.actionstack-app .actionstack-static-text h3 {
+ margin: 0;
+ color: inherit;
+ font-size: 16px;
+}
+.actionstack-app .actionstack-static-text p {
+ margin: 8px 0 0;
+ color: inherit;
+ white-space: pre-wrap;
+ line-height: 1.6;
+}
+.actionstack-app .actionstack-static-text.info,
+.actionstack-app .actionstack-static-text.warning {
+ padding: 16px;
+ border: 1px solid;
+ border-radius: 10px;
+}
+.actionstack-app .actionstack-static-text.info {
+ background: #74c9dc12;
+ border-color: #74c9dc60;
+ color: #b6e8f2;
+}
+.actionstack-app .actionstack-static-text.warning {
+ background: #efbd6412;
+ border-color: #efbd6460;
+ color: #f4d396;
+}
+.actionstack-app[data-theme="light"] .actionstack-static-text.info {
+ background: #eef8fc;
+ border-color: #a8d2df;
+ color: #20566a;
+}
+.actionstack-app[data-theme="light"] .actionstack-static-text.warning {
+ background: #fff7e6;
+ border-color: #e2c185;
+ color: #785016;
+}
+.actionstack-app .canvas-field .actionstack-static-text {
+ margin: 12px 0 0;
+}
+
+/* Give the application its own scroll area inside Splunk dashboard wrappers. */
+.actionstack-app {
+ height: calc(100dvh - var(--actionstack-host-offset, 0px));
+ min-height: 0;
+ overflow: hidden;
+}
+.actionstack-app .main-shell {
+ height: 100%;
+ overflow: auto;
+ overscroll-behavior: contain;
+}
+.actionstack-app .sidebar {
+ position: relative;
+ height: 100%;
+ min-height: 0;
+ overflow-y: auto;
+ scrollbar-width: thin;
+}
+.actionstack-app .sidebar > * {
+ flex-shrink: 0;
+}
+.actionstack-app .topbar {
+ flex-shrink: 0;
+}
+.actionstack-app .profile > span,
+.actionstack-app .top-avatar {
+ flex: 0 0 30px;
+ min-width: 30px;
+ aspect-ratio: 1;
+}
+.actionstack-app .profile > div {
+ min-width: 0;
+}
+.actionstack-app .profile > svg {
+ flex-shrink: 0;
+}
+.actionstack-app .actionstack-disclosure {
+ border-bottom: 1px solid var(--line);
+ margin-bottom: 12px;
+}
+.actionstack-app .actionstack-disclosure > summary {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 8px;
+ padding: 12px 0;
+ color: var(--text);
+ font-weight: 600;
+ cursor: pointer;
+ list-style: none;
+}
+.actionstack-app .actionstack-disclosure > summary::-webkit-details-marker {
+ display: none;
+}
+.actionstack-app .actionstack-disclosure > summary svg {
+ flex-shrink: 0;
+ transition: transform 150ms ease;
+}
+.actionstack-app .actionstack-disclosure[open] > summary svg {
+ transform: rotate(180deg);
+}
+.actionstack-app .actionstack-disclosure-content {
+ padding-bottom: 12px;
+}
+.actionstack-app .actionstack-disclosure .field:last-child {
+ margin-bottom: 0;
+}
+.actionstack-app .actionstack-run-counts {
+ display: inline-flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 5px;
+ font-size: 13px;
+}
+.actionstack-app .actionstack-run-counts > b {
+ white-space: nowrap;
+ margin-right: 3px;
+}
+.actionstack-app .activity-group h4 {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 12px;
+}
+.actionstack-app .sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+ border: 0;
+}
+@media (prefers-reduced-motion: reduce) {
+ .actionstack-app .actionstack-disclosure > summary svg {
+ transition: none;
+ }
+}
diff --git a/frontend/src/submission-activity.ts b/frontend/src/submission-activity.ts
new file mode 100644
index 0000000..7411791
--- /dev/null
+++ b/frontend/src/submission-activity.ts
@@ -0,0 +1,63 @@
+import { useEffect, useState } from "react";
+import { api } from "./api";
+import type { CountGroup } from "./RunCounts";
+
+type Summary = {
+ playbooks: CountGroup;
+ actions: CountGroup;
+ checked_at: string;
+};
+
+export function useSubmissionActivity(
+ ids: string[],
+ enabled: boolean,
+ refresh: number,
+) {
+ const [data, setData] = useState>({});
+ const key = ids.join(",");
+ useEffect(() => {
+ let stopped = false;
+ let timer: ReturnType;
+ setData({});
+ async function poll() {
+ if (stopped) return;
+ if (!document.hidden) {
+ const queue = key ? key.split(",") : [];
+ async function worker() {
+ while (!stopped && !document.hidden && queue.length) {
+ const id = queue.shift()!;
+ try {
+ const result = await api(
+ `/submissions/${id}/activity-summary`,
+ );
+ if (!stopped) setData((prior) => ({ ...prior, [id]: result }));
+ } catch (error) {
+ if (!stopped)
+ setData((prior) => {
+ const group = {
+ total: null,
+ counts: null,
+ truncated: false,
+ error: (error as Error).message,
+ };
+ return {
+ ...prior,
+ [id]: { playbooks: group, actions: group, checked_at: "" },
+ };
+ });
+ }
+ }
+ }
+ // Only the current ten-row page is polled, with three reads in flight.
+ await Promise.all([worker(), worker(), worker()]);
+ }
+ if (!stopped) timer = setTimeout(poll, 30000);
+ }
+ if (enabled && key) void poll();
+ return () => {
+ stopped = true;
+ clearTimeout(timer);
+ };
+ }, [key, enabled, refresh]);
+ return data;
+}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index d94c05b..a9ddcde 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -1,4 +1,5 @@
export type Field = {
+ tone?: "text" | "info" | "warning";
collapsed?: boolean;
lookup?: LookupConfig;
validation?: Omit[];
@@ -109,13 +110,18 @@ export type Activity = {
demo: boolean;
playbooks: RunGroup;
actions: RunGroup;
+ blocks?: RunGroup;
};
export type RunGroup = {
+ counts?: Record | null;
+ notice?: string;
summary_error?: string;
summary_truncated?: boolean;
items: {
- id: number;
+ id: number | string;
name: string;
+ action?: string;
+ block_type?: string;
status: string;
summaries?: {
app_run_id: number;
diff --git a/package-lock.json b/package-lock.json
index 4ca0d9a..0ab138c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "splunk-actionstack",
- "version": "0.5.1",
+ "version": "0.5.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "splunk-actionstack",
- "version": "0.5.1",
+ "version": "0.5.4",
"dependencies": {
"lucide-react": "^0.577.0",
"react": "^19.2.0",
diff --git a/package.json b/package.json
index 71cfa7b..980400b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "splunk-actionstack",
- "version": "0.5.1",
+ "version": "0.5.4",
"private": true,
"type": "module",
"scripts": {
diff --git a/scripts/dev_server.py b/scripts/dev_server.py
index f8ad972..8bdd556 100644
--- a/scripts/dev_server.py
+++ b/scripts/dev_server.py
@@ -45,8 +45,8 @@ def get(self,key): return 'demo-only'
class DemoSoar:
def __init__(self,store): self.store=store
def labels(self): return ['automation_requests','events','phishing','service_requests']
- def activity(self,container_id):
- return {key:{'items':[],'total':0,'truncated':False,'error':None} for key in ['playbooks','actions']}
+ def activity(self,container_id,details=True):
+ return {key:{'items':[],'total':0,'truncated':False,'error':None,'counts':dict.fromkeys(['success','failed','running','pending','cancelled','unknown'],0)} for key in (['playbooks','actions','blocks'] if details else ['playbooks','actions'])}
def ensure(self,kind,payload):
key=payload['source_data_identifier']; existing=self.store.get('remote_'+kind,key)
if existing: return existing['id']
@@ -62,7 +62,7 @@ def search(self,config,term,exact=False):
values=['alice@example.test','alicia@example.test','alex@example.test','bob@example.test','scott@example.test']
value_field,label_field=lookup_fields(config)
options=[{'value':v,'label':v if value_field==label_field else v.split('@')[0].title()+' Example'} for v in values]
- matches=[o for o in options if ((o['value'] in term if isinstance(term,list) else o['value']==term) if exact else any(o[k].lower().startswith(term.lower()) for k in ['value','label']))]
+ matches=[o for o in options if ((o['value'].lower() in [v.lower() for v in term] if isinstance(term,list) else o['value'].lower()==term.lower()) if exact else any(o[k].lower().startswith(term.lower()) for k in ['value','label']))]
return {'options':matches,'more':False}
ACTOR={'username':'demo.user','display_name':'Demo workspace','email':'demo@example.test','roles':['admin','user'],'capabilities':[PREFIX+x for x in ['use','submit','edit','publish','admin','audit','read_team']]}
diff --git a/splunk_actionstack/bin/actionstack/core.py b/splunk_actionstack/bin/actionstack/core.py
index c75a4d6..21cec8b 100644
--- a/splunk_actionstack/bin/actionstack/core.py
+++ b/splunk_actionstack/bin/actionstack/core.py
@@ -12,7 +12,7 @@
APP = 'splunk_actionstack'
PREFIX = 'actionstack_'
DURATIONS = {'4h': 14400, '1d': 86400, '30d': 2592000, '60d': 5184000, '90d': 7776000, 'forever': None}
-TYPES = {'text', 'textarea', 'number', 'email', 'url', 'date', 'datetime', 'select', 'multiselect', 'radio', 'checkbox', 'section', 'lookup', 'text_list', 'lookup_multi'}
+TYPES = {'text', 'textarea', 'number', 'email', 'url', 'date', 'datetime', 'select', 'multiselect', 'radio', 'checkbox', 'section', 'static_text', 'lookup', 'text_list', 'lookup_multi'}
KEY = re.compile(r'^[A-Za-z_][A-Za-z0-9_]{0,63}$')
RESERVED_FIELDS = {'submission_id','form_id','form_version','submitted_at','submitted_by','approval_required','approval_policy','permanent'}
SLUG = re.compile(r'^[a-z][a-z0-9-]{0,63}$')
@@ -84,7 +84,7 @@ def validate_definition(raw, roles):
if not isinstance(value, str) or len(value) > limit or (key == 'title' and not value.strip()):
raise Error(400, 'Invalid form ' + key + '.')
f[key] = value.strip()
- f['icon'] = f.get('icon') if f.get('icon') in ['shield','workflow','search','file','globe','zap'] else 'workflow'
+ f['icon'] = f.get('icon') if f.get('icon') in ['shield','workflow','search','file','globe','zap','scan','server','user'] else 'workflow'
f['accent'] = f.get('accent') if f.get('accent') in ['violet','cyan','rose','mint','amber','indigo','pearl','sunset'] else 'violet'
f['workspace_id']=f.get('workspace_id','security')
if not isinstance(f['workspace_id'],str) or not SLUG.fullmatch(f['workspace_id']): raise Error(400,'Choose a valid workspace.')
@@ -94,7 +94,7 @@ def validate_definition(raw, roles):
raise Error(400, 'Add between 1 and 60 fields.')
keys = set()
for field in fields:
- if not isinstance(field, dict) or set(field) - {'key','type','label','required','default','placeholder','help','options','show_when','min','max','min_length','max_length','cef_key','lookup','required_when','validation','collapsed'}:
+ if not isinstance(field, dict) or set(field) - {'key','type','label','required','default','placeholder','help','options','show_when','min','max','min_length','max_length','cef_key','lookup','required_when','validation','collapsed','tone'}:
raise Error(400, 'Invalid field configuration.')
key = field.get('key', '')
if not isinstance(key, str) or not KEY.fullmatch(key) or key in keys or key in RESERVED_FIELDS:
@@ -102,6 +102,10 @@ def validate_definition(raw, roles):
keys.add(key)
if field.get('type') not in TYPES or not isinstance(field.get('label'), str) or not 1 <= len(field['label']) <= 150:
raise Error(400, 'Every field needs a valid type and label.')
+ if 'tone' in field and (field['type']!='static_text' or field['tone'] not in ['text','info','warning']):
+ raise Error(400,'Static text style must be text, info or warning.')
+ if field['type']=='static_text' and (field.get('required') or field.get('required_when') or field.get('cef_key') or field.get('default') is not None):
+ raise Error(400,'Static text is display-only; it cannot require input, have a default value or map to SOAR.')
if 'collapsed' in field and (field['type']!='section' or type(field['collapsed']) is not bool): raise Error(400,'Collapsed by default is a section option and must be true or false.')
if field['type'] in ['lookup','lookup_multi']:
from .lookups import validate_source
@@ -150,7 +154,7 @@ def validate_definition(raw, roles):
if rule:
if not isinstance(rule,dict) or set(rule)!={'field','equals'} or rule['field'] not in seen or not isinstance(rule['equals'],(str,bool,int,float)):
raise Error(400, 'Conditions must reference an earlier field and a scalar value.')
- seen.add(field['key'])
+ if field['type'] not in ['section','static_text']: seen.add(field['key'])
m=f.setdefault('mapping',{})
if not isinstance(m,dict) or set(m)-{'label','tags','severity','sensitivity','run_automation','policy','title_prefix','approval','enrichment'}:
raise Error(400, 'Invalid SOAR mapping.')
@@ -196,13 +200,13 @@ def validate_definition(raw, roles):
def validate_inputs(form, incoming):
if not isinstance(incoming,dict):
raise Error(400,'Form inputs must be an object.')
- fields=form['fields']; known={f['key'] for f in fields if f['type']!='section'}
+ fields=form['fields']; known={f['key'] for f in fields if f['type'] not in ['section','static_text']}
if set(incoming)-known:
raise Error(400,'Unexpected form fields.',{k:'Unknown field' for k in set(incoming)-known})
output={}; errors={}
for f in fields:
k=f['key']; t=f['type']
- if t=='section':
+ if t in ['section','static_text']:
continue
if not visible(f,output):
if k in incoming and incoming[k] not in [None,'',[]]:
@@ -309,7 +313,7 @@ def event_payload(form, actor, inputs, submission_id, submitted_at, settings):
permanent=inputs['duration']=='forever'
derived={'requested_duration_seconds':DURATIONS[inputs['duration']],'permanent':permanent}
who={k:clone(actor[k]) for k in ['username','roles','email','display_name'] if actor.get(k)}
- envelope={'contract_version':1,'submission_id':submission_id,'submitted_at':submitted_at,'source':{'app':APP,'instance':settings.get('instance_name','Splunk Enterprise')},'form':{'workspace_id':form.get('workspace_id','security'),'id':form['id'],'title':form['title'],'version':form['version'],'fields':{f['key']:f['label'] for f in form['fields'] if f['type']!='section'}},'submitted_by':who,'routing':{'mapping_version':form['revision'],'label':m['label'],'tags':m.get('tags',[])},'inputs':inputs,'derived':derived,'policy':policy}
+ envelope={'contract_version':1,'submission_id':submission_id,'submitted_at':submitted_at,'source':{'app':APP,'instance':settings.get('instance_name','Splunk Enterprise')},'form':{'workspace_id':form.get('workspace_id','security'),'id':form['id'],'title':form['title'],'version':form['version'],'fields':{f['key']:f['label'] for f in form['fields'] if f['type'] not in ['section','static_text']}},'submitted_by':who,'routing':{'mapping_version':form['revision'],'label':m['label'],'tags':m.get('tags',[])},'inputs':inputs,'derived':derived,'policy':policy}
if form.get('validation_policy'): envelope['form']['validation_policy']={k:form['validation_policy'][k] for k in ['id','revision']}
container={'name':m.get('title_prefix',form['title'])+' · '+submission_id,'label':m['label'],'container_type':'default','description':'Submitted through ActionStack by '+actor['username'],'severity':m.get('severity','low'),'sensitivity':m.get('sensitivity','amber'),'status':'new','source_data_identifier':'splunk_actionstack:'+submission_id,'tags':m.get('tags',[]),'run_automation':False,'data':{'actionstack':envelope}}
if settings.get('asset_id'): container['asset_id']=int(settings['asset_id'])
diff --git a/splunk_actionstack/bin/actionstack/field_validation.py b/splunk_actionstack/bin/actionstack/field_validation.py
index 995affe..209850a 100644
--- a/splunk_actionstack/bin/actionstack/field_validation.py
+++ b/splunk_actionstack/bin/actionstack/field_validation.py
@@ -22,7 +22,7 @@ def definition_checks(fields):
for field in fields:
rules=field.get('validation',[])
if not isinstance(rules,list) or len(rules)>64: raise Error(400,'Use at most 64 validation checks per field.')
- if field['type']=='section' and rules: raise Error(400,'Sections cannot have validation checks.')
+ if field['type'] in ['section','static_text'] and rules: raise Error(400,'Sections and static text cannot have validation checks.')
for rule in rules:
if not isinstance(rule,dict) or 'field' in rule or rule.get('operator')=='required': raise Error(400,'Set Required in the field settings.')
op=rule.get('operator')
diff --git a/splunk_actionstack/bin/actionstack/kv_lookup.py b/splunk_actionstack/bin/actionstack/kv_lookup.py
new file mode 100644
index 0000000..f9b8e0d
--- /dev/null
+++ b/splunk_actionstack/bin/actionstack/kv_lookup.py
@@ -0,0 +1,66 @@
+"""Direct, user-authorized KV Store reads for projection-only lookup searches."""
+import json
+import re
+from urllib.parse import quote
+from .core import Error
+
+NAME = re.compile(r'[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}')
+
+
+def collection_rows(rest, username, app, search, fields, term, exact):
+ # Only bypass search dispatch when every command preserves the selected
+ # values. Scalar mvexpand is a no-op; array results use the SPL fallback.
+ parts = search.split(' | ')
+ source = parts[0].split()[-1]
+ for part in parts[1:]:
+ tokens = part.replace(',', ' ').split()
+ if tokens[0].lower() in ['fields', 'table']:
+ projected = tokens[1:]
+ if projected and projected[0] == '+': projected = projected[1:]
+ if not projected or any(not NAME.fullmatch(f) for f in projected) or not set(fields) <= set(projected): return None
+ elif tokens[0].lower() != 'mvexpand' or len(tokens) != 2 or tokens[1] not in fields:
+ return None
+ context = '/servicesNS/' + quote(username, safe='') + '/' + quote(app, safe='')
+ try:
+ definition = rest.call('GET', context + '/data/transforms/lookups/' + quote(source, safe=''))
+ except Error:
+ # CSV lookups and identities without REST definition access can still
+ # use the ordinary search permission path.
+ return None
+ entries = definition.get('entry', []) if isinstance(definition, dict) else []
+ if not isinstance(entries, list) or len(entries) != 1 or not isinstance(entries[0], dict): return None
+ entry = entries[0]
+ config = entry.get('content', {})
+ if not isinstance(config, dict) or config.get('external_type') != 'kvstore' or config.get('filter') or config.get('time_field'): return None
+ collection = config.get('collection')
+ acl = entry.get('acl') or config.get('eai:acl', {})
+ namespace = acl.get('app') if isinstance(acl, dict) else None
+ declared = config.get('fields_list', '')
+ if isinstance(declared, str): declared = re.split(r'[\s,]+', declared)
+ if not isinstance(declared, list) or not set(fields) <= set(declared): return None
+ if not all(isinstance(v, str) and NAME.fullmatch(v) for v in [collection, namespace]): return None
+ # Search results stringify numeric/boolean fields. A regex against those
+ # raw KV types is not equivalent, so accelerate declared string columns only.
+ try:
+ schema = rest.call('GET', '/servicesNS/nobody/' + quote(namespace, safe='') + '/storage/collections/config/' + quote(collection, safe=''))
+ except Error: return None
+ schema_entries = schema.get('entry', []) if isinstance(schema, dict) else []
+ if not isinstance(schema_entries, list) or len(schema_entries) != 1 or not isinstance(schema_entries[0], dict): return None
+ schema_fields = schema_entries[0].get('content', {})
+ if not isinstance(schema_fields, dict) or any(f != '_key' and schema_fields.get('field.' + f) != 'string' for f in fields): return None
+ terms = term if isinstance(term, list) else [term]
+ clauses = [
+ {field: {'$regex': '^' + re.escape(value) + ('$' if exact else ''), '$options': 'i'}}
+ for field in (fields[:1] if exact else dict.fromkeys(fields)) for value in terms
+ ]
+ path = '/servicesNS/nobody/' + quote(namespace, safe='') + '/storage/collections/data/' + quote(collection, safe='')
+ try:
+ rows = rest.call('GET', path, params={
+ 'query': json.dumps({'$or': clauses}), 'limit': 251 if exact else 26,
+ 'fields': ','.join(dict.fromkeys(fields)),
+ })
+ except Error:
+ return None # A lookup search may be allowed when direct KV reads are not.
+ if not isinstance(rows, list): raise Error(502, 'Unexpected KV Store lookup response.')
+ if any(not isinstance(row, dict) or any(f in row and not isinstance(row[f], str) for f in fields) for row in rows): return None
+ return rows
diff --git a/splunk_actionstack/bin/actionstack/lookups.py b/splunk_actionstack/bin/actionstack/lookups.py
index 8e4c3c6..fccb577 100644
--- a/splunk_actionstack/bin/actionstack/lookups.py
+++ b/splunk_actionstack/bin/actionstack/lookups.py
@@ -4,6 +4,7 @@
import time
from urllib.parse import quote
from .core import Error
+from .kv_lookup import collection_rows
SOURCE=re.compile(r'\s*\|\s*inputlookup\s+([A-Za-z0-9_][A-Za-z0-9_.-]{0,127})\s*\|\s*fields\s+([A-Za-z_][A-Za-z0-9_]{0,63})\s*',re.I)
@@ -33,17 +34,28 @@ def pipeline(search):
parts.append(''.join(current).strip())
if parts and not parts[0]: parts=parts[1:]
if not parts or any(not part for part in parts): raise Error(400,'Enter a complete lookup pipeline.')
- source=re.fullmatch(r'inputlookup\s+(?:strict=true\s+)?([A-Za-z0-9_][A-Za-z0-9_.-]{0,127})',parts[0],re.I)
- if not source: raise Error(400,'Start with | inputlookup lookup_name, then add read-only SPL transformations.')
+ tokens=parts[0].split()
+ names=[]; options={}
+ for token in tokens[1:]:
+ option=re.fullmatch(r'(strict)=(true|false)',token,re.I)
+ if option:
+ key,val=option[1].lower(),option[2].lower()
+ if key in options or val!='true': raise Error(400,'Use strict=true only once, or omit it.')
+ options[key]=val
+ elif token.lower().startswith('local='):
+ raise Error(400,'Remove the local option from the lookup search. inputlookup does not support local=true or local=false.')
+ elif re.fullmatch(r'[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}',token): names.append(token)
+ else: raise Error(400,'Unsupported inputlookup option. Use a lookup name and optional strict=true.')
+ if tokens[0].lower()!='inputlookup' or len(names)!=1: raise Error(400,'Start with | inputlookup lookup_name, then add read-only SPL transformations.')
for part in parts[1:]:
command=re.match(r'([a-zA-Z]+)(?:\s|$)',part)
if not command or command[1].lower() not in READ_COMMANDS: raise Error(400,'Unsupported lookup command. Use read-only transformations such as eval, where, table, fields, rename or stats.')
- parts[0]='inputlookup strict=true '+source[1]
+ parts[0]='inputlookup strict=true '+names[0]
return ' | '.join(parts)
def lookup_fields(config):
# Older one-column forms keep their existing value/label mapping.
- legacy=SOURCE.fullmatch(config.get('search',''))
+ legacy=SOURCE.fullmatch('| '+re.sub(r'\bstrict=true\s+', '',pipeline(config.get('search','')),flags=re.I))
value=config.get('value_field',legacy[2] if legacy else '')
label=config.get('label_field',value)
if not all(isinstance(v,str) and IDENTIFIER.fullmatch(v) for v in [value,label]): raise Error(400,'Choose the result field sent to SOAR and the field displayed as its label.')
@@ -65,12 +77,12 @@ def lookup_spl(config,term,exact=False):
if isinstance(term,list) and (not exact or not 1<=len(term)<=25): raise Error(400,'Select 1–25 lookup values.')
if any(not isinstance(t,str) or not 1<=len(t)<=200 or any(ord(c)<32 for c in t) or '`' in t for t in terms): raise Error(400,'Enter up to 200 characters without control characters or backticks.')
if exact:
- comparison=' OR '.join(f"tostring('{column}') = {json.dumps(t,ensure_ascii=False)}" for t in terms)
+ comparison=' OR '.join(f"lower(tostring('{column}')) = {json.dumps(t.lower(),ensure_ascii=False)}" for t in terms)
else:
value=json.dumps(term.lower(),ensure_ascii=False)
comparison=' OR '.join(f"substr(lower(tostring('{field}')),1,{len(term.lower())}) = {value}" for field in dict.fromkeys([column,label]))
columns=' '.join(dict.fromkeys([column,label]))
- return f"| {pipeline(config['search'])} | where ({comparison}) | dedup {column} | head {len(terms) if exact else 26} | fields {columns}",column
+ return f"| {pipeline(config['search'])} | where ({comparison}) | dedup {column} | head {251 if exact else 26} | fields {columns}",column
def truth(value): return value is True or value==1 or value=='1'
@@ -79,10 +91,14 @@ def __init__(self,user_rest,username): self.rest,self.username=user_rest,usernam
def search(self,config,term,exact=False):
spl,column=lookup_spl(config,term,exact)
+ rows=collection_rows(self.rest,self.username,config['app'],pipeline(config['search']),lookup_fields(config),term,exact)
+ if rows is not None: return lookup_options(rows,config,term,exact)
base='/servicesNS/'+quote(self.username,safe='')+'/'+quote(config['app'],safe='')+'/search/jobs'
sid=None
try:
- job=self.rest.call('POST',base,form={'search':spl,'exec_mode':'normal','max_time':'5','auto_cancel':'30','auto_finalize_ec':'0','status_buckets':'0'})
+ # Blocking dispatch avoids repeated status requests while SPL runs.
+ # Inspect the final state anyway: finalized partial results are unsafe.
+ job=self.rest.call('POST',base,form={'search':spl,'exec_mode':'blocking','max_time':'5','auto_cancel':'30','auto_finalize_ec':'0','status_buckets':'0'})
sid=job.get('sid') if isinstance(job,dict) else None
if not isinstance(sid,str) or not re.fullmatch(r'[A-Za-z0-9_.-]+',sid): raise Error(502,'Splunk did not return a lookup search ID.')
path=base+'/'+quote(sid,safe=''); deadline=time.monotonic()+6
@@ -96,21 +112,10 @@ def search(self,config,term,exact=False):
if truth(c.get('isDone')) or c.get('dispatchState')=='DONE': break
time.sleep(0.1)
else: raise Error(504,'Lookup search timed out. Refine the query or use a smaller lookup.')
- result=self.rest.call('GET',path+'/results',params={'count':(len(term) if isinstance(term,list) else 1) if exact else 26,'output_mode':'json'})
+ result=self.rest.call('GET',path+'/results',params={'count':251 if exact else 26,'output_mode':'json'})
if not isinstance(result,dict) or not isinstance(result.get('results'),list) or not isinstance(result.get('messages',[]),list) or any(not isinstance(m,dict) or m.get('type') in ['WARN','ERROR','FATAL'] for m in result.get('messages',[])):
raise Error(503,'Lookup results could not be confirmed. Check the lookup configuration and permissions.')
- _,label_column=lookup_fields(config)
- options=[]; seen=set()
- for row in result['results']:
- if not isinstance(row,dict): raise Error(502,'Invalid lookup result row.')
- if column not in row or label_column not in row: raise Error(400,'Lookup results are missing the configured value or label field. Keep both fields in the final SPL output.')
- value=row[column]; label=row[label_column]
- if not isinstance(value,str) or not value or len(value)>200 or any(ord(c)<32 for c in value): continue
- if not isinstance(label,str) or not label or len(label)>200: continue
- matches=(value in term if isinstance(term,list) else value==term) if exact else any(v.lower().startswith(term.lower()) for v in [value,label])
- if matches and value not in seen:
- seen.add(value); options.append({'value':value,'label':label})
- return {'options':options[:25],'more':len(options)>25}
+ return lookup_options(result['results'],config,term,exact)
except Error as exc:
if exc.status==503 and 'storage' in exc.message.lower(): raise Error(503,'Lookup search is unavailable. The signed-in user needs search capability and read access to this lookup in its app context.')
raise
@@ -118,3 +123,20 @@ def search(self,config,term,exact=False):
if sid and re.fullmatch(r'[A-Za-z0-9_.-]+',sid):
try: self.rest.call('DELETE',base+'/'+quote(sid,safe=''))
except Error: pass # Splunk's auto-cancel/TTL still bounds abandoned jobs.
+
+
+def lookup_options(rows,config,term,exact=False):
+ column,label_column=lookup_fields(config)
+ if exact and len(rows)>250: raise Error(400,'Too many lookup values differ only by case. Refine the lookup source.')
+ requested=[v.lower() for v in (term if isinstance(term,list) else [term])]
+ options=[]; seen=set()
+ for row in rows:
+ if not isinstance(row,dict): raise Error(502,'Invalid lookup result row.')
+ if column not in row or label_column not in row: raise Error(400,'Lookup results are missing the configured value or label field. Keep both fields in the final SPL output.')
+ value=row[column]; label=row[label_column]
+ if not isinstance(value,str) or not value or len(value)>200 or any(ord(c)<32 for c in value): continue
+ if not isinstance(label,str) or not label or len(label)>200: continue
+ matches=value.lower() in requested if exact else any(v.lower().startswith(term.lower()) for v in [value,label])
+ if matches and value.lower() not in seen:
+ seen.add(value.lower()); options.append({'value':value,'label':label})
+ return {'options':options[:25],'more':len(rows)>=26 if not exact else False}
diff --git a/splunk_actionstack/bin/actionstack/run_activity.py b/splunk_actionstack/bin/actionstack/run_activity.py
new file mode 100644
index 0000000..816e3e4
--- /dev/null
+++ b/splunk_actionstack/bin/actionstack/run_activity.py
@@ -0,0 +1,59 @@
+"""Small, data-free projections of SOAR run and block status."""
+import hashlib
+import json
+import re
+
+STATUSES = ('success', 'failed', 'running', 'pending', 'cancelled', 'unknown')
+
+
+def run_status(value):
+ if value == 'failure': return 'failed'
+ return value if isinstance(value, str) and value in STATUSES else 'unknown'
+
+
+def counts(group):
+ if group.get('error'): return None
+ result = dict.fromkeys(STATUSES, 0)
+ for item in group['items']: result[run_status(item.get('status'))] += 1
+ return result
+
+
+def block_rows(results, playbook_run, token=''):
+ """Read datapath names/status only; never project saved block payloads."""
+ rows = {}
+ for path, value in list(results.items())[:1000]:
+ if not isinstance(path, str): continue
+ parts = path.split(':')
+ if not parts[0] or len(parts[0]) > 180: continue
+ filtered = parts[0] == 'filtered-data' and len(parts) > 2
+ name = parts[1] if filtered else parts[0]
+ tail = parts[2:] if filtered else parts[1:]
+ kind = 'Filter' if filtered else 'Format' if 'formatted_data' in tail else 'Utility' if any('custom_function' in p for p in tail) else 'Decision / filter' if any(p.startswith('condition_') for p in tail) else 'Code / saved result'
+ # Display condition rows separately; their statuses need not agree.
+ condition = next((p for p in tail if p.startswith('condition_')), None)
+ key = name + (':' + condition if condition else '')
+ clean = re.sub(r'[\x00-\x1f\x7f]', ' ', key.replace(token, '[redacted]') if token else key)[:180]
+ row = rows.setdefault(key, {'id':str(playbook_run)+':'+hashlib.sha256(key.encode()).hexdigest()[:16], 'name':clean, 'block_type':kind, 'status':'unknown', 'playbook_run_id':playbook_run, 'updated_at':None})
+ # Output or a false condition is not a run success/failure signal.
+ if tail and tail[-1] == 'status': row['status'] = run_status(value)
+ return list(rows.values())[:100]
+
+
+def utility_rows(message, playbook_run, token=''):
+ # Some SOAR versions include custom-function run headers in the playbook
+ # report. Use only those explicit headers, never the function definition.
+ if isinstance(message, str):
+ if len(message) > 512000: return []
+ try: message = json.loads(message)
+ except (ValueError, RecursionError): return []
+ if not isinstance(message, dict) or message.get('playbook_run_id') != playbook_run: return []
+ results = message.get('result')
+ if not isinstance(results, list): return []
+ rows = []
+ for result in results[:100]:
+ if not isinstance(result, dict) or type(result.get('custom_function_run_id')) is not int: continue
+ name = result.get('name') or result.get('custom_function_name')
+ if not isinstance(name, str): continue
+ name = re.sub(r'[\x00-\x1f\x7f]', ' ', name.replace(token, '[redacted]') if token else name)[:180]
+ rows.append({'id':str(playbook_run)+':utility:'+str(result['custom_function_run_id']), 'name':name, 'block_type':'Utility', 'status':run_status(result.get('status')), 'playbook_run_id':playbook_run, 'updated_at':None})
+ return rows
diff --git a/splunk_actionstack/bin/actionstack/service.py b/splunk_actionstack/bin/actionstack/service.py
index e60ff6e..cc4bc4e 100644
--- a/splunk_actionstack/bin/actionstack/service.py
+++ b/splunk_actionstack/bin/actionstack/service.py
@@ -9,7 +9,7 @@
from .workspaces import Workspaces, DEFAULT_WORKSPACE
from .validation_policies import ValidationPolicies, validate_request, block_policy, public_policy
-from .lookups import validate_source
+from .lookups import validate_source,lookup_spl
from .field_validation import inline_form, validate_field_inputs
DEFAULT_SETTINGS={'soar_url':'','instance_name':'Splunk Enterprise','asset_id':None,'ca_pem':'','ignore_certificate_errors':False,'request_timeout':15,'label_prefix':'','revision':0}
@@ -97,7 +97,10 @@ def checked_inputs(self,actor,f,incoming):
if field['type'] in ['lookup','lookup_multi'] and field['key'] in inputs:
result=self.run_lookup(actor,field['lookup'],inputs[field['key']],exact=True)
expected=inputs[field['key']] if isinstance(inputs[field['key']],list) else [inputs[field['key']]]
- if not set(expected).issubset({o['value'] for o in result['options']}): raise Error(400,'Choose a current lookup value.',{field['key']:'This value is unavailable in the lookup. Search and select again.'})
+ found={o['value'].lower():o['value'] for o in result['options']}
+ if any(v.lower() not in found for v in expected): raise Error(400,'Choose a current lookup value.',{field['key']:'This value is unavailable in the lookup. Search and select again.'})
+ canonical=list(dict.fromkeys(found[v.lower()] for v in expected))
+ inputs[field['key']]=canonical if field['type']=='lookup_multi' else canonical[0]
validate_request(f,inputs)
validate_field_inputs(f,inputs)
return inputs
@@ -270,12 +273,18 @@ def submission_fingerprint(self,actor,body):
return {'fingerprint':hashlib.sha256(body['value'].encode('utf-8')).hexdigest()}
def run_lookup(self,actor,config,term,exact=False):
- validate_source(config)
+ lookup_spl(config,term,exact)
if not self.lookup: raise Error(503,'Lookup search is not configured on this search head.')
minute=int(time.time()//60)
+ bucket='lookup:'+digest(actor['username'])
+ # Avoid an HTTP conflict for every prior lookup this minute. The unique
+ # insert still arbitrates concurrent callers and pre-upgrade records.
+ occupied={r['_key'] for r in self.store.list('ratelimits',{'minute':minute,'bucket':bucket})}
for slot in range(60):
+ key=bucket+':'+str(minute)+':'+str(slot)
+ if key in occupied: continue
try:
- self.store.insert('ratelimits',{'_key':'lookup:'+digest(actor['username'])+':'+str(minute)+':'+str(slot),'minute':minute})
+ self.store.insert('ratelimits',{'_key':key,'minute':minute,'bucket':bucket})
break
except Conflict: pass
else: raise Error(429,'Lookup search limit reached. Pause briefly before searching again.')
@@ -297,11 +306,12 @@ def lookup_options(self,actor,body,preview=False):
config=field['lookup']
validate_source(config)
term=body['term']
+ if isinstance(term,list): return self.run_lookup(actor,config,term,exact=True)
if not isinstance(term,str): raise Error(400,'Enter a search term.')
if len(term)len(rows),'error':None}
except Error as exc:
reason='The SOAR identity cannot read these runs.' if exc.status==403 else 'SOAR run status is unavailable. Check the connection and run-read permissions.'
output[key]={'items':[],'total':None,'truncated':False,'error':reason}
actions=output['actions']
- if actions['items']:
+ if details and actions['items']:
try:
summaries=self.action_summaries(container_id)
for row in actions['items']:
@@ -170,6 +172,26 @@ def safe(value,limit=180):
actions['summary_truncated']=summaries['truncated']
except Error:
actions['summary_error']='Action results are unavailable. Check SOAR app-run read permissions.'
+ for group in output.values(): group['counts']=counts(group)
+ if details:
+ blocks=[]; unavailable=False; limited=output['playbooks']['truncated'] or len(reports)>3
+ old_timeout=self.timeout
+ try:
+ self.timeout=min(self.timeout,3)
+ for run in output['playbooks']['items'][:3]:
+ blocks.extend(utility_rows(reports.get(run['id']),run['id'],self.token))
+ try:
+ result=self.call('GET','playbook_run/'+str(run['id'])+'/block_results')
+ if not isinstance(result,dict) or not isinstance(result.get('block_results'),dict): raise Error(502,'Unexpected block results.')
+ raw=result['block_results']
+ projected=block_rows(raw,run['id'],self.token)
+ limited=limited or len(raw)>1000 or len(projected)>=100
+ blocks.extend(projected)
+ except Error: unavailable=True
+ finally: self.timeout=old_timeout
+ output['blocks']={'items':blocks,'total':len(blocks),'truncated':limited,'error':None,
+ 'notice':'Only blocks with results reported by SOAR are listed. Utility blocks appear when SOAR includes their run headers. An unreported status is shown as Unknown.',
+ 'summary_error':'Some block results are unavailable. Check SOAR permissions and version support.' if unavailable or output['playbooks']['error'] else None}
return output
def action_summaries(self,container_id):
diff --git a/splunk_actionstack/bin/actionstack/validation_policies.py b/splunk_actionstack/bin/actionstack/validation_policies.py
index 4beb59b..485c645 100644
--- a/splunk_actionstack/bin/actionstack/validation_policies.py
+++ b/splunk_actionstack/bin/actionstack/validation_policies.py
@@ -28,12 +28,12 @@ def validate_rules(rules,fields=None):
if condition is not None and (not isinstance(condition,dict) or set(condition)!={'field','equals'} or not isinstance(condition['field'],str) or not KEY.fullmatch(condition['field']) or not scalar(condition['equals'])): raise Error(400,'Invalid validation condition.')
if by_key is not None:
f=by_key.get(r['field'])
- if not f or f['type']=='section': raise Error(400,'Validation policy references missing field: '+r['field'])
+ if not f or f['type'] in ['section','static_text']: raise Error(400,'Validation policy references missing field: '+r['field'])
if r['operator'] in ['min','max'] and f['type']!='number': raise Error(400,'Numeric validation requires a number field: '+r['field'])
if r['operator'] in ['regex','ip_address','domain','sha1','sha256'] and f['type'] in ['number','checkbox','multiselect']: raise Error(400,'This validation requires a text field: '+r['field'])
for field_key,value in ([(r['field'],r['value'])] if r['operator'] in ['equals','not_equals'] else [])+([(condition['field'],condition['equals'])] if condition else []):
target=by_key.get(field_key)
- if not target or target['type'] in ['section','multiselect']: raise Error(400,'Validation comparisons require an existing scalar field: '+field_key)
+ if not target or target['type'] in ['section','static_text','multiselect']: raise Error(400,'Validation comparisons require an existing scalar field: '+field_key)
if target['type']=='number' and type(value) not in [int,float] or target['type']=='checkbox' and type(value) is not bool or target['type'] not in ['number','checkbox'] and not isinstance(value,str): raise Error(400,'Validation value type does not match field: '+field_key)
if target['type'] in ['select','radio'] and value not in [o['value'] for o in target.get('options',[])]: raise Error(400,'Validation value is not a choice for field: '+field_key)
diff --git a/splunk_actionstack/default/app.conf b/splunk_actionstack/default/app.conf
index f7baab5..6991e45 100644
--- a/splunk_actionstack/default/app.conf
+++ b/splunk_actionstack/default/app.conf
@@ -8,7 +8,7 @@ label = ActionStack
[launcher]
author = ActionStack
-version = 0.5.1
+version = 0.5.4
description = Role-aware forms and reliable event submission to Splunk SOAR.
[package]
diff --git a/splunk_actionstack/default/collections.conf b/splunk_actionstack/default/collections.conf
index 6a41651..82b9c68 100644
--- a/splunk_actionstack/default/collections.conf
+++ b/splunk_actionstack/default/collections.conf
@@ -5,6 +5,7 @@ accelerated_fields.actor = {"actor.username": 1, "submitted_at": -1}
[actionstack_locks]
[actionstack_ratelimits]
accelerated_fields.minute = {"minute": 1}
+accelerated_fields.bucket = {"bucket": 1, "minute": 1}
[actionstack_settings]
[actionstack_audit]
accelerated_fields.time = {"at": -1}
diff --git a/tests/lookup-search.test.mjs b/tests/lookup-search.test.mjs
index df51638..3bba2ff 100644
--- a/tests/lookup-search.test.mjs
+++ b/tests/lookup-search.test.mjs
@@ -5,3 +5,20 @@ const sleep=ms=>new Promise(r=>setTimeout(r,ms));
test('lookup waits for minimum characters and typing pause',async()=>{const calls=[],results=[];const q=lookupSearch(async t=>{calls.push(t);return t},(v)=>results.push(v));q.set('al');await sleep(60);assert.equal(calls.length,0);q.set('ali');await sleep(20);q.set('alic');await sleep(20);assert.equal(calls.length,0);await sleep(45);assert.deepEqual(calls,['alic']);assert.equal(results.at(-1),'alic');q.dispose();});
test('lookup serializes in-flight requests and discards stale replies',async()=>{const calls=[],results=[],finish=[];const q=lookupSearch(t=>{calls.push(t);return new Promise(r=>finish.push(r))},v=>{if(v)results.push(v)});q.set('ali');await sleep(65);q.set('alice');await sleep(65);assert.deepEqual(calls,['ali']);finish[0]('old');await sleep(0);assert.deepEqual(calls,['ali','alice']);assert.deepEqual(results,[]);finish[1]('new');await sleep(0);assert.deepEqual(results,['new']);q.dispose();});
test('clearing and disposal suppress pending lookup work',async()=>{let calls=0;const q=lookupSearch(async()=>{calls++;return []},()=>{});q.set('ali');q.set('');await sleep(65);assert.equal(calls,0);q.set('alice');q.dispose();await sleep(65);assert.equal(calls,0);});
+test('recent suggestions reuse a field-local cache and expired suggestions refresh',async()=>{
+ let calls=0, now=1000; const original=Date.now; Date.now=()=>now;
+ const results=[]; const q=lookupSearch(async t=>{calls++;return t},v=>{if(v)results.push(v)},3,0);
+ try {
+ q.set('alice'); await sleep(10); q.set(''); q.set('alice');
+ assert.equal(calls,1); assert.deepEqual(results,['alice','alice']);
+ now+=30001; q.set('alice'); await sleep(10); assert.equal(calls,2);
+ const other=lookupSearch(async t=>{calls++;return t},()=>{},3,0);
+ other.set('alice'); await sleep(10); assert.equal(calls,3); other.dispose();
+ } finally { q.dispose(); Date.now=original; }
+});
+test('cache hits invalidate older in-flight responses',async()=>{
+ const results=[]; let finish;
+ const q=lookupSearch(t=>t==='alice'?Promise.resolve(t):new Promise(r=>finish=r),v=>{if(v)results.push(v)},3,0);
+ q.set('alice'); await sleep(10); q.set('bob'); await sleep(10); q.set('alice'); finish('bob'); await sleep(0);
+ assert.deepEqual(results,['alice','alice']); q.dispose();
+});
diff --git a/tests/multi-values.test.mjs b/tests/multi-values.test.mjs
new file mode 100644
index 0000000..0e5b011
--- /dev/null
+++ b/tests/multi-values.test.mjs
@@ -0,0 +1,20 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { mergeValues } from '../frontend/src/multi-values.js';
+
+test('lists accept commas, newlines and semicolons, trim and deduplicate', () => {
+ assert.deepEqual(mergeValues(['alice'], ' alice, bob\r\ncarol; bob,, '), ['alice', 'bob', 'carol']);
+ assert.deepEqual(mergeValues([], 'Alice Example'), ['Alice Example']);
+});
+test('lists enforce the total and per-item limits without changing existing values', () => {
+ const existing = Array.from({length: 24}, (_, i) => String(i));
+ assert.equal(mergeValues(existing, '0,new,new').length, 25);
+ assert.throws(() => mergeValues(existing, 'new,extra'), /25 items/);
+ assert.throws(() => mergeValues(existing, 'x'.repeat(201)), /200 characters/);
+ assert.equal(existing.length, 24);
+});
+
+test('lookup lists deduplicate ignoring case while retaining stored casing', () => {
+ assert.deepEqual(mergeValues(['Alice'], 'ALICE,alice,Bob,BOB', true), ['Alice', 'Bob']);
+ assert.deepEqual(mergeValues([], 'Alice,alice'), ['Alice', 'alice']);
+});
diff --git a/tests/test_approval_activity.py b/tests/test_approval_activity.py
index 96dad94..f3cf4f0 100644
--- a/tests/test_approval_activity.py
+++ b/tests/test_approval_activity.py
@@ -64,8 +64,12 @@ def test_only_event_scoped_safe_summary_is_exposed(self):
self.assertEqual(result['actions']['items'][0]['name'],'block ip')
self.assertNotIn('private',json.dumps(result))
for call in self.remote.call.call_args_list:
- self.assertEqual(call.args[0],'GET'); self.assertEqual(call.kwargs['query']['_filter_container'],17)
- self.assertNotIn('include_expensive',call.kwargs['query'])
+ self.assertEqual(call.args[0],'GET')
+ if call.args[1].endswith('/block_results'):
+ self.assertEqual(call.args[1],'playbook_run/4/block_results')
+ else:
+ self.assertEqual(call.kwargs['query']['_filter_container'],17)
+ self.assertNotIn('include_expensive',call.kwargs['query'])
def test_foreign_or_malformed_rows_fail_closed(self):
for raw in [dict(self.row(),container=18),dict(self.row(),id=True),None]:
self.remote.call=Mock(return_value={'count':1,'data':[raw]})
diff --git a/tests/test_field_catalog.py b/tests/test_field_catalog.py
index d190bb7..10de502 100644
--- a/tests/test_field_catalog.py
+++ b/tests/test_field_catalog.py
@@ -129,13 +129,13 @@ def test_multi_lookup_rechecks_every_value_in_one_search(self):
class BatchLookupTests(unittest.TestCase):
def test_quoted_batch_spl_and_limits(self):
spl,_=lookup_spl(CONFIG,['alice','bob" | delete'],True)
- self.assertIn(' OR ',spl);self.assertIn('head 2',spl);self.assertIn('bob\\" | delete',spl)
+ self.assertIn(' OR ',spl);self.assertIn('head 251',spl);self.assertIn('bob\\" | delete',spl)
for term in [[],['a']*26,['a`b'],['x\n']]:
with self.assertRaises(Error):lookup_spl(CONFIG,term,True)
with self.assertRaises(Error):lookup_spl(CONFIG,['alice'],False)
def test_batch_adapter_returns_only_exact_selected_values(self):
- rest=Mock();rest.call.side_effect=[{'sid':'123'}, {'entry':[{'content':{'isDone':True}}]}, {'results':[{'identity':'alice'},{'identity':'bob'},{'identity':'foreign'}]},{}]
+ rest=Mock();rest.call.side_effect=[None,{'sid':'123'}, {'entry':[{'content':{'isDone':True}}]}, {'results':[{'identity':'alice'},{'identity':'bob'},{'identity':'foreign'}]},{}]
result=LookupSearch(rest,'alice').search(CONFIG,['alice','bob'],True)
self.assertEqual([x['value'] for x in result['options']],['alice','bob'])
- self.assertEqual(rest.call.call_args_list[2].kwargs['params']['count'],2)
+ self.assertEqual(rest.call.call_args_list[3].kwargs['params']['count'],251)
self.assertEqual(rest.call.call_args_list[-1].args[0],'DELETE')
diff --git a/tests/test_kv_lookup.py b/tests/test_kv_lookup.py
new file mode 100644
index 0000000..b140012
--- /dev/null
+++ b/tests/test_kv_lookup.py
@@ -0,0 +1,83 @@
+"""Lookup acceleration must preserve permissions, query semantics and stored values."""
+import json
+import unittest
+from unittest.mock import Mock, patch
+import test_pages as fx
+from actionstack.core import Error, seed_form
+from actionstack.lookups import LookupSearch
+from test_management_lookups import CONFIG
+
+
+def definition(**changes):
+ return {'entry':[{'acl':{'app':'identity_app'},'content':dict(external_type='kvstore',collection='identities',fields_list='_key,identity,display',**changes)}]}
+
+SCHEMA={'entry':[{'content':{'field.identity':'string','field.display':'string'}}]}
+
+class KVLookupTests(unittest.TestCase):
+ def test_kv_prefix_uses_user_namespace_no_search_job_and_escaped_regex(self):
+ rest=Mock(); rest.call.side_effect=[definition(),SCHEMA,[{'identity':'A.b1','display':'Example'}]]
+ config=dict(CONFIG,value_field='identity',label_field='display',search='| inputlookup identities | table identity display')
+ result=LookupSearch(rest,'alice@example.test').search(config,'a.b')
+ self.assertEqual(result['options'],[{'value':'A.b1','label':'Example'}])
+ self.assertEqual(rest.call.call_count,3)
+ first,_,last=rest.call.call_args_list
+ self.assertEqual(first.args,('GET','/servicesNS/alice%40example.test/search/data/transforms/lookups/identities'))
+ self.assertEqual(last.args,('GET','/servicesNS/nobody/identity_app/storage/collections/data/identities'))
+ self.assertEqual(last.kwargs['params']['limit'],26)
+ self.assertEqual(json.loads(last.kwargs['params']['query']),{'$or':[{'identity':{'$regex':r'^a\.b','$options':'i'}},{'display':{'$regex':r'^a\.b','$options':'i'}}]})
+ def test_exact_batch_matches_case_insensitively_and_preserves_canonical_values(self):
+ rest=Mock();rest.call.side_effect=[definition(),SCHEMA,[{'identity':'Alice'},{'identity':'ALICE'},{'identity':'BOB'},{'identity':'bobby'}]]
+ result=LookupSearch(rest,'alice').search(CONFIG,['alice','bob'],True)
+ self.assertEqual(result['options'],[{'value':'Alice','label':'Alice'},{'value':'BOB','label':'BOB'}])
+ self.assertEqual(json.loads(rest.call.call_args.kwargs['params']['query'])['$or'][0],{'identity':{'$regex':'^alice$','$options':'i'}})
+ def test_transformed_queries_do_not_use_the_direct_path(self):
+ for tail in ['eval identity=lower(identity) | fields identity','where enabled=1 | fields identity','fields identity | head 5','fields identity | mvexpand identity limit=10','fields - identity','rename identity as display | table display']:
+ rest=Mock();rest.call.return_value={'sid':'1'}
+ with patch.object(rest,'call',side_effect=Error(503,'dispatch unavailable')) as call:
+ with self.assertRaises(Error): LookupSearch(rest,'alice').search(dict(CONFIG,search='| inputlookup identities | '+tail,value_field='identity'), 'ali')
+ self.assertEqual(call.call_args_list[0].args[0],'POST')
+ self.assertTrue(call.call_args_list[0].args[1].endswith('/search/jobs'))
+ def test_filtered_csv_missing_and_unreadable_definitions_fall_back_to_spl(self):
+ for entry in [None,definition(filter='enabled=1'),definition(time_field='timestamp'),{'entry':[{'content':{'external_type':'csv'}}]},Error(403,'denied')]:
+ rest=Mock();rest.call.side_effect=[entry,{'sid':'1'},{'entry':[{'content':{'isDone':True}}]},{'results':[{'identity':'alice'}]},{}]
+ result=LookupSearch(rest,'alice').search(CONFIG,'ali')
+ self.assertEqual(result['options'][0]['value'],'alice')
+ self.assertTrue(rest.call.call_args_list[1].args[1].endswith('/search/jobs'))
+ def test_unreadable_collection_does_not_retry_with_system_credentials(self):
+ rest=Mock();rest.call.side_effect=[definition(),SCHEMA,Error(403,'denied'),Error(403,'search denied')]
+ with self.assertRaises(Error): LookupSearch(rest,'alice').search(CONFIG,'ali')
+ self.assertEqual(rest.call.call_count,4)
+ self.assertTrue(rest.call.call_args.args[1].startswith('/servicesNS/alice/'))
+ def test_scalar_mvexpand_is_direct_but_arrays_preserve_spl_expansion(self):
+ config=dict(CONFIG,search='| inputlookup identities | fields identity | mvexpand identity',value_field='identity')
+ for rows,direct in [([{'identity':'alice'}],True),([{'identity':['alice','alicia']}],False)]:
+ rest=Mock();rest.call.side_effect=[definition(),SCHEMA,rows]+([] if direct else [{'sid':'1'},{'entry':[{'content':{'isDone':True}}]},{'results':[{'identity':'alice'},{'identity':'alicia'}]},{}])
+ result=LookupSearch(rest,'alice').search(config,'ali')
+ self.assertEqual(len(result['options']),1 if direct else 2)
+ self.assertEqual(rest.call.call_count,3 if direct else 7)
+ def test_untyped_or_numeric_columns_preserve_search_string_conversion(self):
+ for columns in [{},{'field.identity':'number'}]:
+ rest=Mock();rest.call.side_effect=[definition(),{'entry':[{'content':columns}]},{'sid':'1'},{'entry':[{'content':{'isDone':True}}]},{'results':[{'identity':'1234'}]},{}]
+ result=LookupSearch(rest,'alice').search(CONFIG,'123')
+ self.assertEqual(result['options'],[{'value':'1234','label':'1234'}])
+ self.assertTrue(rest.call.call_args_list[2].args[1].endswith('/search/jobs'))
+ def test_unknown_definition_namespace_cannot_select_a_collection(self):
+ data=definition();data['entry'][0]['acl']['app']='../secret'
+ rest=Mock();rest.call.side_effect=[data,Error(503,'search unavailable')]
+ with self.assertRaises(Error):LookupSearch(rest,'alice').search(CONFIG,'ali')
+ self.assertNotIn('/storage/collections/',str(rest.call.call_args_list))
+
+class CanonicalSubmissionTests(unittest.TestCase):
+ setUp=fx.ServiceTests.setUp
+ tearDown=fx.ServiceTests.tearDown
+ def test_single_and_multi_values_are_canonical_in_soar_and_validation(self):
+ for kind,incoming,expected in [('lookup','ALICE','Alice'),('lookup_multi',['ALICE','alice','BOB'],['Alice','Bob'])]:
+ f=seed_form();f['mapping']['policy']='none'
+ f['fields']=[{'key':'identity','type':kind,'label':'Identity','lookup':CONFIG,'validation':[{'operator':'regex','value':'Alice|Bob','message':'Canonical value required'}]}]
+ self.svc.lookup=Mock();self.svc.lookup.search.return_value={'options':[{'value':'Alice','label':'Alice A'},{'value':'Bob','label':'Bob B'}]}
+ inputs=self.svc.checked_inputs(self.user,f,{'identity':incoming})
+ self.assertEqual(inputs['identity'],expected)
+ def test_repeated_reads_do_not_run_bootstrap_migrations(self):
+ with patch.object(self.svc,'bootstrap') as bootstrap,patch.object(self.svc,'lookup_options',return_value={'options':[]}) as lookup:
+ self.svc.dispatch(self.user,'POST','/lookups/options',{'term':'ali'})
+ bootstrap.assert_not_called();lookup.assert_called_once()
diff --git a/tests/test_lookup_pipelines.py b/tests/test_lookup_pipelines.py
index c8724a8..854ebeb 100644
--- a/tests/test_lookup_pipelines.py
+++ b/tests/test_lookup_pipelines.py
@@ -6,6 +6,23 @@
CONFIG={'search':'| inputlookup identities | eval display=first . " " . last | table identity display','value_field':'identity','label_field':'display','app':'search','min_chars':3,'debounce_ms':50}
class LookupPipelines(unittest.TestCase):
+ def test_strict_option_preserves_pipeline_and_legacy_mapping(self):
+ for source in ['inputlookup identities','inputlookup strict=true identities','inputlookup identities strict=true']:
+ c=dict(CONFIG,search='| '+source+' | fields identity | mvexpand identity',label_field='identity')
+ spl,_=lookup_spl(c,'ali')
+ self.assertIn('| inputlookup strict=true identities | fields identity | mvexpand identity | where',spl)
+ legacy={'search':'| inputlookup identities | fields identity','app':'search','min_chars':3,'debounce_ms':50}
+ self.assertEqual(validate_source(legacy),('identity','identity'))
+ def test_local_option_is_rejected_with_correction(self):
+ for option in ['local=true','local=false','LOCAL=TRUE']:
+ for source in [f'inputlookup {option} identities',f'inputlookup identities {option}']:
+ with self.subTest(source=source),self.assertRaises(Error) as caught:
+ validate_source(dict(CONFIG,search=source))
+ self.assertEqual(caught.exception.status,400)
+ self.assertIn('Remove the local option',caught.exception.message)
+ def test_invalid_inputlookup_options_are_rejected(self):
+ for source in ['inputlookup identities local=yes','inputlookup identities strict=true strict=true','inputlookup identities strict=false','inputlookup identities append=true','inputlookup identities other']:
+ with self.subTest(source=source),self.assertRaises(Error):validate_source(dict(CONFIG,search=source))
def test_transformations_execute_before_matching_both_columns(self):
spl,column=lookup_spl(CONFIG,'Alice')
self.assertEqual(column,'identity');self.assertIn('eval display=first . " " . last | table identity display | where',spl)
@@ -23,7 +40,7 @@ def test_all_supported_transformations_and_mapping_identifiers(self):
with self.subTest(field=field),self.assertRaises(Error):validate_source(dict(CONFIG,value_field=field))
def test_value_is_validated_exactly_not_its_label(self):
spl,_=lookup_spl(CONFIG,['user-1','user-2'],True)
- self.assertIn("tostring('identity') = \"user-1\"",spl);self.assertNotIn("tostring('display')",spl);self.assertIn('head 2',spl)
+ self.assertIn("lower(tostring('identity')) = \"user-1\"",spl);self.assertNotIn("tostring('display')",spl);self.assertIn('head 251',spl)
def test_label_matches_return_the_underlying_value(self):
rest=Mock();rest.call.side_effect=[{'sid':'1'},{'entry':[{'content':{'isDone':True}}]},{'results':[{'identity':'uid-17','display':'Alice Example'},{'identity':'uid-17','display':'Alice duplicate'}]},{}]
result=LookupSearch(rest,'requester').search(CONFIG,'ali')
diff --git a/tests/test_management_lookups.py b/tests/test_management_lookups.py
index c5c950b..ea53d2a 100644
--- a/tests/test_management_lookups.py
+++ b/tests/test_management_lookups.py
@@ -71,6 +71,28 @@ def test_lookup_minimum_and_role_check_precede_search(self):
request={'form_id':'block-object','form_version':2,'field':'identity','term':'al'}
self.assertEqual(self.svc.lookup_options(self.user,request)['options'],[]);self.svc.lookup.search.assert_not_called()
with self.assertRaises(Error):self.svc.lookup_options(self.user,{'config':CONFIG,'term':'ali'},True)
+ def test_pasted_values_use_exact_batch_search_with_the_same_acl(self):
+ self.lookup_form();self.svc.lookup=Mock();self.svc.lookup.search.return_value={'options':[],'more':False}
+ request={'form_id':'block-object','form_version':2,'field':'identity','term':['a','bob']}
+ self.svc.lookup_options(self.user,request)
+ self.svc.lookup.search.assert_called_once_with(CONFIG,['a','bob'],True)
+ for values in [[],['x']*26,[None],['bad\nvalue'],['x'*201]]:
+ with self.subTest(values=values),self.assertRaises(Error):self.svc.lookup_options(self.user,dict(request,term=values))
+ with self.assertRaises(Error):self.svc.lookup_options(self.user,dict(request,config=CONFIG))
+ with self.assertRaises(Error):self.svc.lookup_options(fx.actor(caps=['use']),request)
+ def test_lookup_budget_skips_used_slots_but_handles_insert_races(self):
+ self.svc.lookup=Mock()
+ with patch('actionstack.service.time.time',return_value=6000):
+ for _ in range(40):self.svc.run_lookup(self.user,CONFIG,'ali')
+ original=self.store.insert
+ with patch.object(self.store,'insert',wraps=original) as insert:
+ self.svc.run_lookup(self.user,CONFIG,'ali')
+ self.assertEqual(insert.call_count,1)
+ # A competing member claims the next free slot after our read.
+ with patch.object(self.store,'insert',side_effect=[Conflict('claimed'),{}]) as insert:
+ self.svc.run_lookup(self.user,CONFIG,'ali')
+ self.assertEqual(insert.call_count,2)
+ self.assertNotEqual(insert.call_args_list[0].args[1]['_key'],insert.call_args_list[1].args[1]['_key'])
def test_lookup_budget_is_shared(self):
self.svc.lookup=Mock();self.svc.lookup.search.return_value={'options':[],'more':False}
for _ in range(60):self.svc.run_lookup(self.user,CONFIG,'ali')
@@ -87,25 +109,26 @@ def test_spl_literal_escaping_and_source_allowlist(self):
for term in ['`macro`','bad\nvalue','a'*201]:
with self.assertRaises(Error):lookup_spl(CONFIG,term)
def test_user_namespace_bounded_results_and_cleanup(self):
- rest=Mock();rest.call.side_effect=[{'sid':'123.4'},{'entry':[{'content':{'isDone':'1'}}]},{'results':[{'identity':'alice'+str(i)} for i in range(26)]},{}]
+ rest=Mock();rest.call.side_effect=[None,{'sid':'123.4'},{'entry':[{'content':{'isDone':'1'}}]},{'results':[{'identity':'alice'+str(i)} for i in range(26)]},{}]
result=LookupSearch(rest,'alice@example.test').search(CONFIG,'ali')
self.assertEqual(len(result['options']),25);self.assertTrue(result['more'])
self.assertTrue(rest.call.call_args_list[0].args[1].startswith('/servicesNS/alice%40example.test/search/'))
- self.assertEqual(rest.call.call_args_list[0].kwargs['form']['max_time'],'5')
+ self.assertEqual(rest.call.call_args_list[1].kwargs['form']['max_time'],'5')
+ self.assertEqual(rest.call.call_args_list[1].kwargs['form']['exec_mode'],'blocking')
self.assertEqual(rest.call.call_args_list[-1].args[0],'DELETE')
self.assertEqual(rest.call.call_args_list[-2].kwargs['params']['count'],26)
def test_partial_failed_or_malformed_results_fail_closed_and_cleanup(self):
for c in [{'isFinalized':True},{'isFailed':'1'},{'dispatchState':'FAILED'}]:
- rest=Mock();rest.call.side_effect=[{'sid':'1'},{'entry':[{'content':c}]},{}]
+ rest=Mock();rest.call.side_effect=[None,{'sid':'1'},{'entry':[{'content':c}]},{}]
with self.assertRaises(Error):LookupSearch(rest,'alice').search(CONFIG,'ali')
self.assertEqual(rest.call.call_args_list[-1].args[0],'DELETE')
for results in [{'results':[None]},{'results':[],'messages':[{'type':'WARN'}]}]:
- rest=Mock();rest.call.side_effect=[{'sid':'1'},{'entry':[{'content':{'isDone':True}}]},results,{}]
+ rest=Mock();rest.call.side_effect=[None,{'sid':'1'},{'entry':[{'content':{'isDone':True}}]},results,{}]
with self.assertRaises(Error):LookupSearch(rest,'alice').search(CONFIG,'ali')
- def test_exact_search_uses_one_result_and_is_case_sensitive(self):
- spl,_=lookup_spl(CONFIG,'Alice',True);self.assertIn('head 1',spl);self.assertNotIn('lower(',spl)
- rest=Mock();rest.call.side_effect=[{'sid':'1'},{'entry':[{'content':{'isDone':True}}]},{'results':[{'identity':'alice'}]},{}]
- self.assertEqual(LookupSearch(rest,'alice').search(CONFIG,'Alice',True)['options'],[])
+ def test_exact_search_is_bounded_case_insensitive_and_returns_canonical_value(self):
+ spl,_=lookup_spl(CONFIG,'Alice',True);self.assertIn('head 251',spl);self.assertIn('lower(',spl)
+ rest=Mock();rest.call.side_effect=[None,{'sid':'1'},{'entry':[{'content':{'isDone':True}}]},{'results':[{'identity':'alice'}]},{}]
+ self.assertEqual(LookupSearch(rest,'alice').search(CONFIG,'Alice',True)['options'],[{'value':'alice','label':'alice'}])
class SummaryTests(unittest.TestCase):
def test_result_projection_includes_data_and_redacts_credentials(self):
diff --git a/tests/test_run_activity.py b/tests/test_run_activity.py
new file mode 100644
index 0000000..b1ef816
--- /dev/null
+++ b/tests/test_run_activity.py
@@ -0,0 +1,75 @@
+import json
+import unittest
+from unittest.mock import Mock, patch
+import test_pages as fx
+from actionstack.core import Error,seed_form,validate_definition
+from actionstack.soar import Soar
+from actionstack.run_activity import block_rows,utility_rows
+
+class RunActivityTests(unittest.TestCase):
+ def test_custom_names_status_counts_and_summary_reads_skip_details(self):
+ remote=Soar({'soar_url':'https://soar.example.test'},'token')
+ remote.call=Mock(side_effect=[{'count':1,'data':[{'id':2,'container':17,'playbook':3,'status':'running'}]},
+ {'count':4,'data':[dict(id=i,container=17,name=name,action='run query',status=status) for i,name,status in [(3,'Scan workstation','success'),(4,'Check scan completion','failure'),(5,'Fetch results','running'),(6,'Post processing','future-state')]]}])
+ result=remote.activity(17,details=False)
+ self.assertEqual(remote.call.call_count,2)
+ self.assertEqual(result['actions']['items'][0]['name'],'Scan workstation')
+ self.assertEqual(result['actions']['items'][0]['action'],'run query')
+ self.assertEqual(result['actions']['counts'],dict(success=1,failed=1,running=1,pending=0,cancelled=0,unknown=1))
+ self.assertNotIn('blocks',result)
+ def test_missing_status_group_is_not_zero_success(self):
+ remote=Soar({'soar_url':'https://soar.example.test'},'token');remote.call=Mock(side_effect=Error(403,'denied'))
+ result=remote.activity(17,details=False)
+ self.assertIsNone(result['playbooks']['total']);self.assertIsNone(result['playbooks']['counts'])
+ def test_block_results_are_scoped_to_verified_runs_and_separate_from_actions(self):
+ remote=Soar({'soar_url':'https://soar.example.test'},'token')
+ remote.call=Mock(side_effect=[{'count':1,'data':[{'id':2,'container':17,'status':'success'}]},{'count':0,'data':[]},
+ {'block_results':{'scan_message:formatted_data':'private result','filtered-data:choose_targets:condition_1':{},'filtered-data:choose_targets:condition_1:status':'success','decision_1:condition_2':{'result':False}}}])
+ result=remote.activity(17)
+ self.assertEqual(remote.call.call_args.args,('GET','playbook_run/2/block_results'))
+ blocks=result['blocks']['items']
+ self.assertEqual([(b['name'],b['status']) for b in blocks],[('scan_message','unknown'),('choose_targets:condition_1','success'),('decision_1:condition_2','unknown')])
+ self.assertEqual(blocks[0]['block_type'],'Format');self.assertEqual(result['actions']['total'],0)
+ self.assertNotIn('private result',json.dumps(result))
+ def test_foreign_runs_never_trigger_block_reads(self):
+ remote=Soar({'soar_url':'https://soar.example.test'},'');remote.call=Mock(return_value={'count':1,'data':[{'id':3,'container':99}]})
+ remote.activity(17)
+ self.assertEqual(remote.call.call_count,2)
+ def test_utility_requires_explicit_run_header_and_never_uses_overall_status(self):
+ report={'playbook_run_id':4,'status':'success','result':[{'name':'Normalize usernames','custom_function_name':'to_lower','custom_function_run_id':19,'status':'failure','custom_function_results':[{'data':'private'}]}]}
+ rows=utility_rows(json.dumps(report),4)
+ self.assertEqual(rows[0]['name'],'Normalize usernames');self.assertEqual(rows[0]['status'],'failed')
+ self.assertNotIn('private',json.dumps(rows));self.assertEqual(utility_rows(report,99),[])
+ report['result'][0].pop('status');self.assertEqual(utility_rows(report,4)[0]['status'],'unknown')
+ def test_token_in_block_key_is_not_exposed_in_name_or_id(self):
+ self.assertNotIn('secret-token',json.dumps(block_rows({'secret-token:formatted_data':'private'},4,'secret-token')))
+ def test_large_block_histories_are_bounded(self):
+ rows=block_rows({f'block_{n}:formatted_data':'x'*50 for n in range(2000)},4)
+ self.assertEqual(len(rows),100)
+ def test_new_icons_survive_definition_validation_and_explicit_automation_choice(self):
+ for icon in ['scan','server','user']:
+ f=seed_form();f['icon']=icon
+ for enabled in [True,False]:
+ f['mapping']['run_automation']=enabled
+ saved=validate_definition(f,fx.ROLES)
+ self.assertEqual(saved['icon'],icon);self.assertEqual(saved['mapping']['run_automation'],enabled)
+
+class ActivitySummaryServiceTests(unittest.TestCase):
+ setUp=fx.ServiceTests.setUp
+ tearDown=fx.ServiceTests.tearDown
+ def test_summary_acl_and_payload_excludes_action_results(self):
+ receipt=self.svc.submit(self.user,fx.ServiceTests.body(self));remote=Mock();remote.timeout=15
+ remote.activity.return_value={'actions':{'items':[{'summaries':['private']}],'total':1,'counts':{'success':1},'truncated':False,'error':None},'playbooks':{'items':[],'total':0,'counts':{},'truncated':False,'error':None}}
+ self.factory.reset_mock();self.factory.return_value=remote;self.factory.side_effect=None
+ with self.assertRaises(Error):self.svc.activity(fx.actor('bob'),receipt['id'],True)
+ self.factory.assert_not_called()
+ result=self.svc.dispatch(self.user,'GET','/submissions/'+receipt['id']+'/activity-summary')
+ remote.activity.assert_called_once_with(receipt['container_id'],details=False)
+ self.assertEqual(remote.timeout,5);self.assertNotIn('private',json.dumps(result));self.assertNotIn('items',result['actions'])
+ def test_summary_budget_does_not_consume_receipt_or_delivery_budget(self):
+ receipt=self.svc.submit(self.user,fx.ServiceTests.body(self))
+ with patch('actionstack.service.time.time',return_value=6000):
+ for _ in range(60): self.svc.activity(self.user,receipt['id'],True)
+ with self.assertRaises(Error) as err:self.svc.activity(self.user,receipt['id'],True)
+ self.assertEqual(err.exception.status,429)
+ self.svc.activity(self.user,receipt['id']);self.svc.delivery_budget(self.user)
diff --git a/tests/test_static_text.py b/tests/test_static_text.py
new file mode 100644
index 0000000..b80fb88
--- /dev/null
+++ b/tests/test_static_text.py
@@ -0,0 +1,63 @@
+import unittest
+import test_pages as fx
+from actionstack.core import Error, seed_form, validate_definition, validate_inputs, event_payload, visible
+
+
+def form():
+ f = seed_form()
+ f['mapping']['policy'] = 'none'
+ f['fields'] = [
+ {'key': 'enabled', 'type': 'checkbox', 'label': 'Enable change'},
+ {'key': 'notice', 'type': 'static_text', 'label': 'Service interruption',
+ 'help': 'This change disconnects users.\nNotify your team first.', 'tone': 'warning',
+ 'show_when': {'field': 'enabled', 'equals': True}},
+ ]
+ return f
+
+
+class StaticTextTests(unittest.TestCase):
+ def test_static_text_styles_round_trip_with_visibility(self):
+ for tone in ['text', 'info', 'warning']:
+ f = form(); f['fields'][1]['tone'] = tone
+ saved = validate_definition(f, fx.ROLES)
+ self.assertEqual(saved['fields'][1], f['fields'][1])
+ self.assertFalse(visible(saved['fields'][1], {'enabled': False}))
+ self.assertTrue(visible(saved['fields'][1], {'enabled': True}))
+
+ def test_no_input_required_and_no_static_text_in_soar_payload(self):
+ f = validate_definition(form(), fx.ROLES)
+ f.update(version=1, revision=1)
+ for enabled in [False, True]:
+ inputs = validate_inputs(f, {'enabled': enabled})
+ self.assertEqual(inputs, {'enabled': enabled})
+ container, artifact = event_payload(f, fx.actor(), inputs, 'sid', 'now', {})
+ self.assertEqual(container['data']['actionstack']['form']['fields'], {'enabled': 'Enable change'})
+ self.assertEqual(artifact['data']['actionstack']['inputs'], inputs)
+ self.assertNotIn('actionstack_notice', artifact['cef'])
+ with self.assertRaises(Error):
+ validate_inputs(f, {'enabled': True, 'notice': 'forged'})
+
+ def test_static_text_cannot_require_validate_or_map_input(self):
+ for patch in [{'required': True}, {'default': 'x'}, {'cef_key': 'message'},
+ {'required_when': [{'field': 'enabled', 'equals': True}]},
+ {'validation': [{'operator': 'equals', 'value': 'x', 'message': 'Invalid'}]},
+ {'tone': 'unknown'}, {'help': 'x' * 2001}]:
+ f = form(); f['fields'][1].update(patch)
+ with self.subTest(patch=patch), self.assertRaises(Error):
+ validate_definition(f, fx.ROLES)
+
+ def test_static_text_cannot_control_other_fields(self):
+ f = form()
+ f['fields'].append({'key': 'name', 'type': 'text', 'label': 'Name',
+ 'show_when': {'field': 'notice', 'equals': 'x'}})
+ with self.assertRaises(Error): validate_definition(f, fx.ROLES)
+ f = form(); f['fields'][0]['tone'] = 'info'
+ with self.assertRaises(Error): validate_definition(f, fx.ROLES)
+
+ def test_static_text_persists_through_save_and_publish(self):
+ fixture = fx.ServiceTests(); fixture.setUp()
+ try:
+ saved = fixture.svc.save_form(fx.ADMIN, {'form': form(), 'expected_revision': 1}, True)
+ self.assertEqual(fixture.svc.published(saved['id'])['fields'][1], form()['fields'][1])
+ finally:
+ fixture.tearDown()