diff --git a/CHANGELOG.md b/CHANGELOG.md
index a1e105c..96464f3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,17 @@
# Changelog
+## 0.5.6
+
+- Load standard Linux CA bundles alongside Python trust defaults for SOAR HTTPS connections. Preserve explicit runtime CA overrides and certificate/hostname verification.
+- Report certificate verification and TLS handshake failures separately from connectivity timeouts.
+- Add optional retention for delivered submissions, disabled by default. Remove receipt data in bounded, cluster-coordinated batches when submissions are listed; retain failed requests, audit entries, and duplicate-prevention markers. SOAR events are unaffected.
+
+## 0.5.5
+
+- Add an optional receipt timeline with submission, delivery, and reported SOAR run updates. Keep records without timestamps separate from dated updates.
+- Export receipts as JSON with submitted fields, the form snapshot, and available SOAR activity, including result limits and refresh errors.
+- Export the filtered submissions list across pages as CSV, including delivery details, submitted fields, export identity, and scope. Spreadsheet formula values are escaped.
+
## 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.
diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md
index cb7953a..8e76dc4 100644
--- a/DEPLOYMENT.md
+++ b/DEPLOYMENT.md
@@ -109,7 +109,33 @@ Delivery locks do not expire automatically. If a handler crashes while holding a
- 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, 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.
+- The app does not run a background retry or retention scheduler. Optional receipt cleanup runs when submissions are listed, as described below. 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.
For a Splunk Web CSRF error, sign in again and check that the reverse proxy preserves session cookies, `X-Requested-With`, and `X-Splunk-Form-Key`. For a missing-label error, create the configured label in SOAR or change the form's mapping and publish it. Retrying an existing submission keeps its original label.
+
+## Receipt timeline and audit exports
+
+Switch a receipt from **Grouped** to **Timeline** to see submission, delivery, and SOAR run updates ordered by time. SOAR run timestamps are last updates, not start or completion times. Undated blocks appear after dated entries as **Time not reported**. This is a current snapshot; previous status changes and individual delivery attempts are not reconstructed.
+
+**Export JSON** on a receipt downloads the submitted fields, form definition snapshot, delivery details, available SOAR activity, and timeline. The file records the exporting user, app version, export time, and SOAR refresh time. Missing activity, refresh errors, and truncated result flags remain in the export. The button waits for an active activity refresh; failed reads still allow exporting the receipt and any previously loaded activity.
+
+**Export CSV** on Submissions includes all records in the selected workspace/ownership filter across pages, up to the list's 200 most recent accessible records. It exports delivery details and submitted fields as JSON in an `inputs_json` column; use receipt JSON for SOAR run details. The CSV states its scope and exporting identity. Formula-like spreadsheet values are prefixed with an apostrophe.
+
+Exports use only records already returned by the existing receipt access checks. They are snapshots for audit review, not complete historical or tamper-evident audit logs. They do not change retention or retrieve unbounded SOAR history.
+
+## SOAR certificate trust
+
+SOAR HTTPS requests originate from Splunk's Python runtime on a search head. A trusted certificate in an administrator's browser or in another application's trust store does not configure that runtime. ActionStack loads Python's default CA sources, then supplements them with available standard Linux bundles (Debian/Ubuntu `/etc/ssl/certs/ca-certificates.crt`, RHEL-family `/etc/pki/tls/certs/ca-bundle.crt` and `/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem`, plus `/etc/ssl/ca-bundle.pem` and `/etc/ssl/cert.pem`). A previously saved app CA chain is also loaded.
+
+Explicit `SSL_CERT_FILE` or `SSL_CERT_DIR` environment overrides are respected; with either set, the additional Linux bundles are not loaded. The Splunk service account must be able to read the configured trust sources. Trust the CA on every search head, configure SOAR to serve its intermediate certificates, and use a URL whose hostname appears in the certificate. Certificate verification failures now include OpenSSL's bounded verification message; they are distinct from TLS handshake failures and network timeouts.
+
+**Ignore certificate validation** remains opt-in. It disables certificate and hostname checks only for this app's SOAR connection. Updating ActionStack does not enable it or change system trust configuration.
+
+## Delivered submission retention
+
+In **Settings → Delivered submission retention**, choose **Keep indefinitely** (default), or 7, 30, 60, 90, 180, 365, or 730 days, then **Save settings**. Only app administrators can change this app-wide policy. Export required receipts before enabling it: removed fields and receipt details cannot be restored through the app.
+
+Cleanup runs when submissions are listed or refreshed. A shared KV Store slot limits cleanup to one batch of up to 100 records per hour across the search heads. The age is measured from the last delivery update, and only records whose delivery status is `submitted` qualify. Recent records, failed/unconfirmed/pending deliveries, and records with a delivery lock remain untouched. An already running batch uses the policy it started with. Cleanup is not scheduled while the app is idle, so a backlog may take multiple visits and batches.
+
+Each removed receipt is replaced at the same KV key by a minimal marker containing its submission ID, `expired` status, and expiration time. This atomic replacement removes the form, submitted fields, actor, connection snapshot, and payloads without creating a gap that could allow duplicate delivery. Markers are excluded from receipt lists and retained indefinitely; do not delete them as part of routine cleanup. Reusing an expired request's submission key is rejected. SOAR events/artifacts, form versions, and ActionStack audit entries are not deleted. Batch counts are recorded as `submissions.expired` audit events.
diff --git a/README.md b/README.md
index 4e9f46d..8cad513 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,10 @@ Build forms in Splunk that submit events to Splunk SOAR.
[Splunkbase](https://splunkbase.splunk.com/app/9812) · [Issues](https://github.com/Shorton88/ActionStack/issues) · [Contributing](CONTRIBUTING.md)
+## About this project
+
+ActionStack is an AI-driven project attempting to build something useful for the community. It is developed and maintained on a best-effort basis and is provided "as is," without warranty of any kind. See the [MIT license](LICENSE) for the full terms.
+
## Features
- Team workspaces with access controlled by Splunk roles.
@@ -12,6 +16,8 @@ Build forms in Splunk that submit events to Splunk SOAR.
- Drafts, publishing, version history, cloning, and recoverable form deletion.
- Configurable SOAR labels, tags, CEF mappings, and approval requirements handled by your playbooks.
- Paginated submission history with playbook/action status counts, delivery retries, and receipts with custom action names, reported block results, summaries, and data.
+- Optional receipt timeline and JSON receipt / CSV submission exports for audit review.
+- Optional retention for delivered submissions, with SOAR events retained.
- Light, dark, and system themes.
## Installation
diff --git a/frontend/src/AutomationActivity.tsx b/frontend/src/AutomationActivity.tsx
index 0253006..3438596 100644
--- a/frontend/src/AutomationActivity.tsx
+++ b/frontend/src/AutomationActivity.tsx
@@ -2,7 +2,9 @@ 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";
+import { ReceiptTimeline } from "./ReceiptTimeline";
+import type { ActivitySnapshot } from "./receipt-audit.js";
+import type { Activity, RunGroup, Submission } from "./types";
function Runs({ title, group }: { title: string; group: RunGroup }) {
return (
@@ -106,24 +108,23 @@ function Runs({ title, group }: { title: string; group: RunGroup }) {
}
export function AutomationActivity({
- id,
- containerId,
- enabled,
+ submission,
+ onSnapshot,
}: {
- id: string;
- containerId: number | null;
- enabled: boolean;
+ submission: Submission;
+ onSnapshot: (snapshot: ActivitySnapshot) => void;
}) {
+ const { id, container_id: containerId } = submission;
+ const enabled = submission.form.mapping.run_automation;
+ const [view, setView] = useState("grouped");
const [data, setData] = useState(null);
const [error, setError] = useState("");
- const [busy, setBusy] = useState(false);
+ const [busy, setBusy] = useState(Boolean(containerId));
const [refresh, setRefresh] = useState(0);
useEffect(() => {
let stopped = false;
let timer: ReturnType;
- setData(null);
- setError("");
- setBusy(false);
+
async function poll() {
if (stopped || !containerId) return;
if (!document.hidden) {
@@ -148,6 +149,10 @@ export function AutomationActivity({
clearTimeout(timer);
};
}, [id, containerId, refresh]);
+ useEffect(
+ () => onSnapshot({ data, error, loading: busy }),
+ [data, error, busy, onSnapshot],
+ );
return (
+
+ setView("grouped")}
+ >
+ Grouped
+
+ setView("timeline")}
+ >
+ Timeline
+
+
+ {error && (
+
+ Status unavailable: {error}
+ {data
+ ? " Displayed activity is from the last successful refresh."
+ : ""}
+
+ )}
+ {view === "timeline" && (
+
+ )}
{!containerId ? (
Waiting for a SOAR event ID.
) : (
<>
- {error && (
-
- Status unavailable: {error}
- {data
- ? " The results below are from the last successful refresh."
- : ""}
-
- )}
{!data && !error && (
{busy
@@ -194,9 +218,15 @@ export function AutomationActivity({
{data.demo && (
Demo mode does not run playbooks.
)}
-
-
- {data.blocks && }
+ {view === "grouped" && (
+ <>
+
+
+ {data.blocks && (
+
+ )}
+ >
+ )}
Last checked {new Date(data.checked_at).toLocaleTimeString()} ·
Refreshes every 30 seconds while this receipt is visible.
diff --git a/frontend/src/ReceiptTimeline.tsx b/frontend/src/ReceiptTimeline.tsx
new file mode 100644
index 0000000..2617192
--- /dev/null
+++ b/frontend/src/ReceiptTimeline.tsx
@@ -0,0 +1,79 @@
+import { receiptTimeline } from "./receipt-audit.js";
+import type { Activity, Submission } from "./types";
+
+export function ReceiptTimeline({
+ submission,
+ activity,
+}: {
+ submission: Submission;
+ activity: Activity | null;
+}) {
+ const rows = receiptTimeline(submission, activity);
+ return (
+
+
+ Oldest update first. Times show the latest reported updates. Previous
+ status changes and individual delivery attempts are not retained here.
+
+ {activity &&
+ Object.entries({
+ playbooks: activity.playbooks,
+ actions: activity.actions,
+ blocks: activity.blocks,
+ }).map(
+ ([key, group]) =>
+ group && (
+
+ {group.error && (
+
+ {key}: {group.error}
+
+ )}
+ {(group.truncated ||
+ group.summary_truncated ||
+ group.summary_error ||
+ group.notice) && (
+
+ {key}:{" "}
+ {group.notice ||
+ "Some results are limited or unavailable. Use Grouped view for details and SOAR for the complete history."}
+
+ )}
+
+ ),
+ )}
+
+ {rows.map((row) => (
+
+
+ {row.kind} ·{" "}
+ {row.at ? (
+
+ {new Date(row.at).toLocaleString(undefined, {
+ timeZoneName: "short",
+ })}
+
+ ) : (
+ "Time not reported"
+ )}
+
+
+ {row.name}
+
+ {row.status === "submitted"
+ ? "Delivered"
+ : row.status === "success"
+ ? "Succeeded"
+ : row.status.charAt(0).toUpperCase() +
+ row.status.slice(1).replaceAll("_", " ")}
+
+
+
+ {row.detail}
+
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/connection-settings.js b/frontend/src/connection-settings.js
index e05e041..b04d085 100644
--- a/frontend/src/connection-settings.js
+++ b/frontend/src/connection-settings.js
@@ -10,6 +10,7 @@ export function connectionSettingsPayload(settings, token = "") {
ca_pem: settings.ca_pem,
ignore_certificate_errors: settings.ignore_certificate_errors ?? false,
request_timeout: settings.request_timeout,
+ retention_days: settings.retention_days ?? 0,
label_prefix: settings.label_prefix ?? "",
revision: settings.revision,
...(token ? { token } : {}),
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index f8ea266..d650f23 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -7,6 +7,7 @@ import {
import React, { useEffect, useState, useRef } from "react";
import { createRoot } from "react-dom/client";
import {
+ Download,
ArrowUpRight,
ArrowRight,
ArrowLeft,
@@ -70,6 +71,12 @@ import { ApprovalRules } from "./ApprovalRules";
import { Disclosure } from "./Disclosure";
import { RunCounts } from "./RunCounts";
import { useSubmissionActivity } from "./submission-activity";
+import {
+ downloadAudit,
+ receiptExport,
+ submissionsCsv,
+} from "./receipt-audit.js";
+import type { ActivitySnapshot } from "./receipt-audit.js";
import { AutomationActivity } from "./AutomationActivity";
import { approvalRequired } from "./approval.js";
import { version as appVersion } from "../../package.json";
@@ -912,12 +919,34 @@ function App() {
Follow workspace requests from submission to SOAR delivery.
- refresh().catch((e) => setError(e.message))}
- >
-
- Refresh
-
+
+ {
+ const exported_at = new Date().toISOString();
+ downloadAudit(
+ submissionsCsv(submissions, {
+ exported_at,
+ exported_by: ctx.username,
+ app_version: appVersion,
+ workspace_id: workspaceId,
+ mine,
+ }),
+ `actionstack-submissions-${exported_at.slice(0, 10)}.csv`,
+ "text/csv;charset=utf-8",
+ );
+ }}
+ >
+
+ Export CSV
+
+ refresh().catch((e) => setError(e.message))}
+ >
+
+ Refresh
+
+
Showing up to 200 recent submissions you have permission to
- view. Run counts refresh every 30 seconds for this page.
+ view. Run counts refresh every 30 seconds for this page. Export
+ CSV includes all {submissions.length} filtered records across
+ pages, submitted fields, and delivery details. Export a receipt
+ for its SOAR activity.
@@ -1122,7 +1154,9 @@ function App() {
{detail && (
setDetail(null)}
onRetry={async () => {
const r = await api(
@@ -1377,16 +1411,23 @@ function RequestForm({
}
function Receipt({
submission: s,
+ exportedBy,
onClose,
onRetry,
}: {
submission: Submission;
+ exportedBy: string;
onClose: () => void;
onRetry: () => Promise;
}) {
const [busy, setBusy] = useState(false),
[error, setError] = useState(""),
[raw, setRaw] = useState(false);
+ const [activity, setActivity] = useState({
+ data: null,
+ error: "",
+ loading: Boolean(s.container_id),
+ });
return (
@@ -1445,9 +1486,8 @@ function Receipt({
setRaw(!raw)}>
@@ -1455,8 +1495,34 @@ function Receipt({
{raw && {JSON.stringify(s.inputs, null, 2)} }
+
+ Export JSON includes this receipt, submitted fields, and the last
+ loaded SOAR activity with its refresh time and any result limits. It
+ is a snapshot, not a complete audit log.
+
-
+
+
+ downloadAudit(
+ JSON.stringify(
+ receiptExport(s, activity, {
+ exported_at: new Date().toISOString(),
+ exported_by: exportedBy,
+ app_version: appVersion,
+ }),
+ null,
+ 2,
+ ),
+ `actionstack-receipt-${s.id}.json`,
+ "application/json",
+ )
+ }
+ >
+
+ Export JSON
+
Close
{s.event_url && (
Request timeout
@@ -3331,6 +3397,37 @@ function ConnectionSettings({
))}
+
+ Delivered submission retention
+
+ changeSettings({
+ ...settings,
+ retention_days: Number(e.target.value),
+ })
+ }
+ >
+ Keep indefinitely
+ {[7, 30, 60, 90, 180, 365, 730].map((days) => (
+
+ Remove after {days} days
+
+ ))}
+
+
+ Applies app-wide to delivered receipts, measured from their last
+ delivery update. Cleanup removes up to 100 per hour when
+ Submissions is opened or refreshed. Failed and unconfirmed
+ requests remain available.
+
+
+ Removal of receipts and submitted fields is permanent. Export
+ audit records first. SOAR events and ActionStack audit entries
+ are retained, along with a minimal submission ID marker to
+ prevent duplicate delivery.
+
+
{result && (
@@ -3371,7 +3468,7 @@ function ConnectionSettings({
- Save connection
+ Save settings
{dirty && (
diff --git a/frontend/src/receipt-audit.js b/frontend/src/receipt-audit.js
new file mode 100644
index 0000000..60375ea
--- /dev/null
+++ b/frontend/src/receipt-audit.js
@@ -0,0 +1,203 @@
+/** @typedef {import('./types').Submission} Submission */
+/** @typedef {import('./types').Activity} Activity */
+/** @typedef {{data: Activity | null, error: string, loading: boolean}} ActivitySnapshot */
+
+/** @param {string | null | undefined} value */
+function timestamp(value) {
+ if (!value) return null;
+ const ms = Date.parse(value);
+ return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
+}
+
+/** Current observations, not reconstructed run or delivery history.
+ * @param {Submission} submission
+ * @param {Activity | null} activity */
+export function receiptTimeline(submission, activity) {
+ const rows = [
+ {
+ id: "submitted",
+ kind: "Submission",
+ name: `Submitted by ${submission.submitted_by}`,
+ detail: submission.id,
+ status: "recorded",
+ at: timestamp(submission.submitted_at),
+ },
+ {
+ id: "delivery",
+ kind: "Delivery",
+ name: "Latest delivery update",
+ detail: `${submission.attempts} delivery attempt${submission.attempts === 1 ? "" : "s"}`,
+ status: submission.status,
+ at: timestamp(submission.updated_at),
+ },
+ ];
+ for (const [key, kind] of [
+ ["playbooks", "Playbook"],
+ ["actions", "Action"],
+ ["blocks", "Block"],
+ ]) {
+ const group =
+ activity?.[/** @type {'playbooks'|'actions'|'blocks'} */ (key)];
+ for (const run of group?.items || []) {
+ rows.push({
+ id: `${key}:${run.id}`,
+ kind: run.block_type || kind,
+ name: run.name,
+ detail: `${key === "blocks" ? "Block" : "Run"} #${run.id}${run.playbook_run_id ? ` · Playbook run #${run.playbook_run_id}` : ""}`,
+ status: run.status,
+ at: timestamp(run.updated_at),
+ });
+ }
+ }
+ return rows.sort((a, b) => {
+ if (!a.at) return b.at ? 1 : 0;
+ if (!b.at) return -1;
+ return a.at.localeCompare(b.at);
+ });
+}
+
+/** Only export the public receipt fields; never connection or delivery credentials.
+ * @param {Submission} s */
+function receiptRecord(s) {
+ return {
+ id: s.id,
+ workspace_id: s.form.workspace_id || "security",
+ form_id: s.form_id,
+ form_title: s.form_title,
+ form_version: s.form_version,
+ submitted_by: s.submitted_by,
+ submitted_at: s.submitted_at,
+ updated_at: s.updated_at,
+ status: s.status,
+ attempts: s.attempts,
+ error: s.error,
+ inputs: s.inputs,
+ approval: s.approval ?? null,
+ container_id: s.container_id,
+ artifact_id: s.artifact_id,
+ event_url: s.event_url,
+ demo: s.demo,
+ form_snapshot: {
+ id: s.form.id,
+ version: s.form.version,
+ revision: s.form.revision,
+ title: s.form.title,
+ fields: s.form.fields,
+ mapping: s.form.mapping,
+ updated_by: s.form.updated_by,
+ updated_at: s.form.updated_at,
+ },
+ };
+}
+
+/** @param {Submission} submission
+ * @param {ActivitySnapshot} snapshot
+ * @param {{exported_at: string, exported_by: string, app_version: string}} meta */
+export function receiptExport(submission, snapshot, meta) {
+ return {
+ schema_version: 1,
+ export_type: "actionstack_receipt",
+ ...meta,
+ scope:
+ "Current receipt and last loaded SOAR activity. Not a complete or tamper-evident audit log.",
+ activity_note:
+ "Run timestamps are last updates, not start or completion times. Missing times, errors, truncation flags, and result limits are preserved. Includes manual runs and reruns on the event.",
+ receipt: receiptRecord(submission),
+ activity: snapshot.data,
+ activity_error: snapshot.error || null,
+ activity_loading: snapshot.loading,
+ timeline: receiptTimeline(submission, snapshot.data),
+ };
+}
+
+/** Quote every cell and neutralize spreadsheet formulas, including whitespace prefixes.
+ * @param {unknown} value */
+export function csvCell(value) {
+ let text =
+ value == null
+ ? ""
+ : typeof value === "object"
+ ? JSON.stringify(value)
+ : String(value);
+ if (/^[\s\u0000-\u001f]*[=+@-]/.test(text) || /^[\t\r\n]/.test(text))
+ text = "'" + text;
+ return '"' + text.replaceAll('"', '""') + '"';
+}
+
+/** @param {Submission[]} submissions
+ * @param {{exported_at: string, exported_by: string, app_version: string, workspace_id: string, mine: boolean}} meta */
+export function submissionsCsv(submissions, meta) {
+ const columns = [
+ "exported_at",
+ "exported_by",
+ "app_version",
+ "scope",
+ "workspace_filter",
+ "ownership_filter",
+ "submission_id",
+ "workspace_id",
+ "form_id",
+ "form_title",
+ "form_version",
+ "submitted_by",
+ "submitted_at",
+ "updated_at",
+ "delivery_status",
+ "delivery_attempts",
+ "delivery_error",
+ "soar_label",
+ "soar_event_id",
+ "artifact_id",
+ "event_url",
+ "automation_enabled",
+ "approval_required",
+ "approval_policy",
+ "demo",
+ "inputs_json",
+ ];
+ const rows = submissions.map((s) => [
+ meta.exported_at,
+ meta.exported_by,
+ meta.app_version,
+ "Filtered recent submissions (up to 200); delivery snapshot; SOAR activity is in receipt exports",
+ meta.workspace_id,
+ meta.mine ? "mine" : "workspace",
+ s.id,
+ s.form.workspace_id || "security",
+ s.form_id,
+ s.form_title,
+ s.form_version,
+ s.submitted_by,
+ s.submitted_at,
+ s.updated_at,
+ s.status,
+ s.attempts,
+ s.error,
+ s.form.mapping.label,
+ s.container_id,
+ s.artifact_id,
+ s.event_url,
+ s.form.mapping.run_automation,
+ s.approval?.approval_required,
+ s.approval?.approval_policy,
+ s.demo,
+ s.inputs,
+ ]);
+ return (
+ "\uFEFF" +
+ [columns, ...rows].map((row) => row.map(csvCell).join(",")).join("\r\n") +
+ "\r\n"
+ );
+}
+
+/** @param {string} contents @param {string} filename @param {string} mime */
+export function downloadAudit(contents, filename, mime) {
+ const url = URL.createObjectURL(new Blob([contents], { type: mime }));
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = filename.replace(/[^a-zA-Z0-9._-]/g, "_");
+ document.body.append(link);
+ link.click();
+ link.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+}
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 9806233..78343db 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -3812,3 +3812,44 @@
transition: none;
}
}
+
+.actionstack-app .actionstack-receipt-timeline ol {
+ list-style: none;
+ margin: 20px 0 20px 6px;
+ padding: 0 0 0 20px;
+ border-left: 2px solid var(--line);
+}
+.actionstack-app .actionstack-receipt-timeline li {
+ position: relative;
+ padding: 0 0 24px;
+}
+.actionstack-app .actionstack-receipt-timeline li::before {
+ content: "";
+ position: absolute;
+ left: -26px;
+ top: 5px;
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background: var(--muted);
+}
+.actionstack-app .actionstack-receipt-timeline li > div {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+ margin-top: 5px;
+}
+.actionstack-app .actionstack-receipt-timeline b {
+ overflow-wrap: anywhere;
+ min-width: 0;
+ font-size: 14px;
+}
+.actionstack-app .actionstack-receipt-actions {
+ flex-wrap: wrap;
+}
+.actionstack-app .actionstack-timeline-reference {
+ display: block;
+ margin-top: 4px;
+ overflow-wrap: anywhere;
+}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index a9ddcde..2c887a8 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -93,6 +93,7 @@ export type Submission = {
demo: boolean;
};
export type Settings = {
+ retention_days: number;
label_prefix: string;
soar_url: string;
instance_name: string;
diff --git a/package-lock.json b/package-lock.json
index 0ab138c..2ee5267 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "splunk-actionstack",
- "version": "0.5.4",
+ "version": "0.5.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "splunk-actionstack",
- "version": "0.5.4",
+ "version": "0.5.6",
"dependencies": {
"lucide-react": "^0.577.0",
"react": "^19.2.0",
diff --git a/package.json b/package.json
index 980400b..bb1bc76 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "splunk-actionstack",
- "version": "0.5.4",
+ "version": "0.5.6",
"private": true,
"type": "module",
"scripts": {
diff --git a/scripts/dev_server.py b/scripts/dev_server.py
index 8bdd556..9a9b00a 100644
--- a/scripts/dev_server.py
+++ b/scripts/dev_server.py
@@ -25,10 +25,21 @@ def get(self,c,k):
with self.connect() as conn:
row=conn.execute('SELECT document FROM records WHERE collection=? AND key=?',(c,k)).fetchone()
return json.loads(row[0]) if row else None
- def list(self,c,query=None):
+ def list(self,c,query=None,limit=None):
with self.connect() as conn: rows=conn.execute('SELECT document FROM records WHERE collection=? ORDER BY key',(c,)).fetchall()
items=[json.loads(row[0]) for row in rows]
- return [x for x in items if all(x.get(k)==v for k,v in (query or {}).items())]
+ def matches(record):
+ for key,value in (query or {}).items():
+ actual=record.get(key)
+ if isinstance(value,dict):
+ if set(value)=={'$ne'}:
+ if actual==value['$ne']: return False
+ elif set(value)=={'$lt'}:
+ if not isinstance(actual,type(value['$lt'])) or not actual=now-timedelta(days=days): continue
+ # Replace atomically at the SAME key. Never leave an absent key that
+ # another search head could reuse to create a second SOAR event.
+ service.store.put('submissions',{'_key':sid,'id':sid,'status':'expired','expired_at':now.astimezone(timezone.utc).isoformat().replace('+00:00','Z')})
+ removed+=1
+ if removed:
+ service.audit({'username':'system'},'submissions.expired',{'count':removed,'retention_days':days,'cutoff':cutoff})
diff --git a/splunk_actionstack/bin/actionstack/service.py b/splunk_actionstack/bin/actionstack/service.py
index cc4bc4e..68b74cd 100644
--- a/splunk_actionstack/bin/actionstack/service.py
+++ b/splunk_actionstack/bin/actionstack/service.py
@@ -12,7 +12,7 @@
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}
+DEFAULT_SETTINGS={'soar_url':'','instance_name':'Splunk Enterprise','asset_id':None,'ca_pem':'','ignore_certificate_errors':False,'request_timeout':15,'retention_days':0,'label_prefix':'','revision':0}
class Service(Workspaces,ValidationPolicies):
def __init__(self, store, secrets, remote_factory, roles, demo=False, lookup=None,role_manager=None):
@@ -107,8 +107,10 @@ def checked_inputs(self,actor,f,incoming):
def list_submissions(self,actor,body):
if set(body)-{'workspace_id','mine'} or not isinstance(body.get('workspace_id','*'),str) or type(body.get('mine',False)) is not bool: raise Error(400,'Invalid submission filter.')
+ from .retention import age_out_submissions
+ age_out_submissions(self)
workspace=body.get('workspace_id','*'); out=[]
- for r in self.store.list('submissions'):
+ for r in self.store.list('submissions',{'status':{'$ne':'expired'}}):
if workspace!='*' and r['form'].get('workspace_id',DEFAULT_WORKSPACE)!=workspace: continue
if body.get('mine') and r['actor']['username']!=actor['username']: continue
try: out.append(self.receipt(r,actor))
@@ -224,14 +226,16 @@ def save_settings(self,actor,body):
allowed_keys=set(DEFAULT_SETTINGS)|{'token'}
if set(body)-allowed_keys: raise Error(400,'Unknown connection settings.')
from .soar import validate_url, validate_ca
+ current=self.settings()
s={k:body.get(k,DEFAULT_SETTINGS[k]) for k in DEFAULT_SETTINGS}
+ s['retention_days']=body.get('retention_days',current.get('retention_days',0))
+ if type(s['retention_days']) is not int or s['retention_days'] not in [0,7,30,60,90,180,365,730]: raise Error(400,'Choose a supported submission retention period.')
s['soar_url']=validate_url(s['soar_url'])
validate_ca(s['ca_pem'],s['ignore_certificate_errors'])
if not isinstance(s['instance_name'],str) or not 1<=len(s['instance_name'])<=120: raise Error(400,'Enter an instance name.')
if not isinstance(s['label_prefix'],str) or not re.fullmatch(r'[a-zA-Z0-9_-]{0,64}',s['label_prefix']): raise Error(400,'Use up to 64 letters, numbers, underscores or hyphens for the label prefix.')
if s['request_timeout'] not in [5,10,15,20,30]: raise Error(400,'Choose a supported timeout.')
if s['asset_id'] is not None and (type(s['asset_id'])!=int or s['asset_id']<=0): raise Error(400,'Source asset ID must be a positive integer.')
- current=self.settings()
if body.get('revision')!=current['revision']: raise Conflict()
token=body.get('token')
if token is not None and (not isinstance(token,str) or len(token)>4096 or any(c in token for c in '\r\n')): raise Error(400,'Invalid token.')
@@ -252,6 +256,7 @@ def connection_test(self,actor):
return {'ok':True,'labels':labels,'demo':self.demo,'message':message}
def receipt(self,record,actor):
+ if record.get('status')=='expired': raise Error(404,'Receipt is unavailable or has aged out under the retention policy.')
own=record['actor']['username']==actor['username']
team=record['form'].get('access',{}).get('team_roles',[])
if not (own or capable(actor,'admin') or (capable(actor,'read_team') and bool(set(team)&set(actor['roles'])) and self.workspace_allowed(actor,record['form'].get('workspace_id',DEFAULT_WORKSPACE),active=False))): raise Error(404,'Submission not found.')
@@ -351,6 +356,7 @@ def submit(self,actor,body):
fingerprint=digest({'form_version':body['form_version'],'inputs':body['inputs']})
existing=self.store.get('submissions',sid)
if existing:
+ if existing.get('status')=='expired': raise Error(410,'This request has aged out. Its submission key cannot be reused.')
if existing['fingerprint']!=fingerprint: raise Conflict('This submission key was already used for different data.')
return self.receipt(existing,actor)
if body['form_version']!=f['version']: raise Conflict('This form has a newer version. Refresh before submitting.')
@@ -363,6 +369,7 @@ def submit(self,actor,body):
try: self.store.insert('submissions',record)
except Conflict:
existing=self.store.get('submissions',sid)
+ if existing and existing.get('status')=='expired': raise Error(410,'This request has aged out. Its submission key cannot be reused.')
if not existing or existing['fingerprint']!=fingerprint: raise Conflict()
return self.receipt(existing,actor)
self.audit(actor,'submission.accepted',sid)
diff --git a/splunk_actionstack/bin/actionstack/soar.py b/splunk_actionstack/bin/actionstack/soar.py
index 00362d6..d243022 100644
--- a/splunk_actionstack/bin/actionstack/soar.py
+++ b/splunk_actionstack/bin/actionstack/soar.py
@@ -2,10 +2,34 @@
from .run_activity import counts, run_status, block_rows, utility_rows
import json
import ssl
+import os
+import sys
import re
from urllib import request, parse, error
from .core import Error
+# Splunk's bundled OpenSSL can use a different default CA path than Linux.
+LINUX_CA_BUNDLES=(
+ '/etc/ssl/certs/ca-certificates.crt',
+ '/etc/pki/tls/certs/ca-bundle.crt',
+ '/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem',
+ '/etc/ssl/ca-bundle.pem',
+ '/etc/ssl/cert.pem',
+)
+
+def load_system_ca_bundles(context):
+ if not sys.platform.startswith('linux'): return
+ # Respect explicit runtime CA overrides rather than widening their scope.
+ paths=ssl.get_default_verify_paths()
+ if any(os.environ.get(key) for key in (paths.openssl_cafile_env,paths.openssl_capath_env) if key): return
+ loaded=set()
+ for path in LINUX_CA_BUNDLES:
+ resolved=os.path.realpath(path)
+ if resolved in loaded or not os.path.isfile(path): continue
+ try: context.load_verify_locations(cafile=path)
+ except (OSError,ssl.SSLError): continue
+ loaded.add(resolved)
+
MAX_RESPONSE=4*1024*1024
MAX_ACTION_DATA_PREVIEW=16000
MAX_ACTIVITY_DATA=256000
@@ -61,6 +85,7 @@ def validate_ca(pem,ignore_certificate_errors=False):
ctx.verify_mode=ssl.CERT_NONE
return ctx
ctx=ssl.create_default_context()
+ load_system_ca_bundles(ctx)
if pem:
try: ctx.load_verify_locations(cadata=pem)
except (ssl.SSLError,ValueError): raise Error(400,'Enter a valid PEM certificate chain.')
@@ -125,7 +150,13 @@ def call(self,method,path,payload=None,query=None):
except (ValueError,UnicodeError): result={}
if method=='POST' and isinstance(result,dict) and (result.get('existing_container_id') or result.get('existing_artifact_id')): return result
self.rejected(method,path,exc.code,result)
- except (error.URLError,TimeoutError,OSError): raise Error(504,f'SOAR delivery could not be confirmed during {method} /rest/{path}. Check connectivity and the CA chain before retrying.')
+ except (error.URLError,TimeoutError,OSError) as exc:
+ reason=exc.reason if isinstance(exc,error.URLError) else exc
+ if isinstance(reason,ssl.SSLCertVerificationError):
+ detail=response_detail({'message':getattr(reason,'verify_message','') or str(reason)},self.token)
+ raise Error(502,'SOAR certificate verification failed on the Splunk search head. '+detail+' Check the CA trust on every search head, the server certificate chain, and that the SOAR URL matches a certificate DNS name or IP SAN.')
+ if isinstance(reason,ssl.SSLError): raise Error(502,'The TLS handshake with SOAR failed. Check the server TLS configuration and certificate chain.')
+ raise Error(504,f'SOAR delivery could not be confirmed during {method} /rest/{path}. Check connectivity before retrying.')
except (ValueError,UnicodeError): raise Error(502,f'SOAR returned an invalid response during {method} /rest/{path}.')
def labels(self):
diff --git a/splunk_actionstack/bin/actionstack/splunk_store.py b/splunk_actionstack/bin/actionstack/splunk_store.py
index a644ae9..3bb17a3 100644
--- a/splunk_actionstack/bin/actionstack/splunk_store.py
+++ b/splunk_actionstack/bin/actionstack/splunk_store.py
@@ -28,12 +28,13 @@ def path(self,collection,key=None):
base='/servicesNS/nobody/'+APP+'/storage/collections/data/actionstack_'+collection
return base+('/'+quote(key,safe='') if key is not None else '')
def get(self,collection,key): return self.rest.call('GET',self.path(collection,key))
- def list(self,collection,query=None):
+ def list(self,collection,query=None,limit=None):
output=[]; offset=0
while True:
- batch=self.rest.call('GET',self.path(collection),params={'query':json.dumps(query or {}),'limit':500,'skip':offset,'sort':'_key:1'})
+ batch=self.rest.call('GET',self.path(collection),params={'query':json.dumps(query or {}),'limit':min(500,limit-len(output)) if limit is not None else 500,'skip':offset,'sort':'_key:1'})
if batch is None: raise Error(503,'App KV Store collections are missing. Install the full app bundle.')
output.extend(batch)
+ if limit is not None and len(output)>=limit: return output[:limit]
if len(batch)<500: return output
offset+=500
if offset>=50000: raise Error(503,'Collection size exceeded this release’s limit. Apply the retention runbook.')
diff --git a/splunk_actionstack/default/app.conf b/splunk_actionstack/default/app.conf
index 6991e45..ba0d165 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.4
+version = 0.5.6
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 82b9c68..d4dd475 100644
--- a/splunk_actionstack/default/collections.conf
+++ b/splunk_actionstack/default/collections.conf
@@ -1,6 +1,7 @@
[actionstack_forms]
accelerated_fields.form = {"id": 1, "revision": 1}
[actionstack_submissions]
+accelerated_fields.retention = {"status": 1, "updated_at": 1}
accelerated_fields.actor = {"actor.username": 1, "submitted_at": -1}
[actionstack_locks]
[actionstack_ratelimits]
diff --git a/tests/connection-settings.test.mjs b/tests/connection-settings.test.mjs
index 3287247..98a8cfc 100644
--- a/tests/connection-settings.test.mjs
+++ b/tests/connection-settings.test.mjs
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import {connectionSettingsPayload} from '../frontend/src/connection-settings.js';
test('connection edits send only writable settings, excluding KV and response metadata',()=>{
- const editable={soar_url:'https://soar.example.test',instance_name:'Edited',asset_id:17,ca_pem:'saved-ca',ignore_certificate_errors:true,request_timeout:20,label_prefix:'automation_',revision:4};
+ const editable={soar_url:'https://soar.example.test',instance_name:'Edited',asset_id:17,ca_pem:'saved-ca',ignore_certificate_errors:true,request_timeout:20,retention_days:90,label_prefix:'automation_',revision:4};
const response={...editable,_key:'4',_user:'nobody',secret_ref:'internal',token_configured:true,demo:false,unexpected:'storage metadata'};
assert.deepEqual(connectionSettingsPayload(response),editable);
assert.deepEqual(connectionSettingsPayload(response,'replacement-token'),{...editable,token:'replacement-token'});
@@ -14,6 +14,7 @@ test('older connection responses default certificate validation on without repla
const payload=connectionSettingsPayload({soar_url:'https://soar.example.test',instance_name:'Old',asset_id:null,ca_pem:'saved-ca',request_timeout:15,revision:1});
assert.equal(payload.ignore_certificate_errors,false);
assert.equal(payload.label_prefix,'');
+ assert.equal(payload.retention_days,0);
assert.equal(payload.ca_pem,'saved-ca');
assert.ok(!Object.hasOwn(payload,'token'));
});
diff --git a/tests/receipt-audit.test.mjs b/tests/receipt-audit.test.mjs
new file mode 100644
index 0000000..0d712ab
--- /dev/null
+++ b/tests/receipt-audit.test.mjs
@@ -0,0 +1,61 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { csvCell, receiptExport, receiptTimeline, submissionsCsv } from '../frontend/src/receipt-audit.js';
+
+const submission = {
+ id:'receipt-1', form_id:'scan', form_title:'Scan endpoints', form_version:2,
+ form:{id:'scan', title:'Scan endpoints', version:2, revision:3, workspace_id:'security', fields:[{key:'targets',type:'text_multi',label:'Targets'}],mapping:{label:'actionstack_security',run_automation:true}},
+ inputs:{targets:['HOST-A','HOST-B'],reason:'Requested, by audit\nwith "quotes"'},
+ submitted_by:'alice',submitted_at:'2026-09-21T10:00:00Z',updated_at:'2026-09-21T10:00:02Z',
+ status:'submitted',attempts:1,error:null,container_id:21,artifact_id:22,event_url:'https://soar.example/mission/21',demo:false,
+ approval:{approval_required:false,approval_policy:'none'},
+};
+const group = items => ({items,total:items.length,truncated:false,error:null});
+const activity = {
+ checked_at:'2026-09-21T10:10:00Z', automation_enabled:true,demo:false,
+ playbooks:group([{id:1,name:'Scan playbook',status:'running',updated_at:'2026-09-21T10:02:00Z'}]),
+ actions:group([{id:2,name:'Scan target endpoints',status:'success',playbook_run_id:1,updated_at:'2026-09-21T12:01:00+02:00',summaries:[{app_run_id:3,status:'success',data:[{value:'x'}],data_truncated:true}]}]),
+ blocks:group([{id:'block:1',name:'Format report',block_type:'Format',status:'unknown',updated_at:null}]),
+};
+const meta={exported_at:'2026-09-21T11:00:00Z',exported_by:'auditor',app_version:'0.5.5',workspace_id:'security',mine:false};
+
+test('timeline orders actual update times, normalizes zones, and leaves undated blocks last',()=>{
+ const result=receiptTimeline(submission,activity);
+ assert.deepEqual(result.map(r=>r.id),['submitted','delivery','actions:2','playbooks:1','blocks:block:1']);
+ assert.equal(result[2].at,'2026-09-21T10:01:00.000Z');
+ assert.equal(result[4].at,null);
+ assert.equal(result[4].status,'unknown');
+});
+test('invalid and absent dates do not become fabricated event times',()=>{
+ const result=receiptTimeline({...submission,updated_at:'not a timestamp'},null);
+ assert.equal(result.length,2);assert.equal(result[1].at,null);
+ assert.equal(result[1].name,'Latest delivery update');
+});
+test('receipt export preserves submitted values, result limits and stale-read failures, but excludes connection internals',()=>{
+ const source={...submission,connection:{token:'secret'},actor:{session:'secret'},container_payload:{private:'secret'},fingerprint:'secret'};
+ const result=receiptExport(source,{data:activity,error:'Refresh failed',loading:false},meta);
+ assert.equal(result.schema_version,1);assert.equal(result.exported_by,'auditor');
+ assert.deepEqual(result.receipt.inputs,submission.inputs);
+ assert.equal(result.activity.actions.items[0].summaries[0].data_truncated,true);
+ assert.equal(result.activity.checked_at,activity.checked_at);assert.equal(result.activity_error,'Refresh failed');
+ assert.equal(result.receipt.form_snapshot.version,2);
+ assert.ok(!JSON.stringify(result).includes('secret'));
+});
+test('unavailable activity is explicit and does not prevent a delivery receipt export',()=>{
+ const result=receiptExport(submission,{data:null,error:'Access unavailable',loading:false},meta);
+ assert.equal(result.activity,null);assert.equal(result.activity_error,'Access unavailable');assert.equal(result.timeline.length,2);
+});
+test('CSV neutralizes formulas, including control and whitespace prefixes',()=>{
+ for(const input of ['=HYPERLINK("x")','+cmd','-1+2','@SUM(A1)',' =1+2','\ttext','\r=1+2','\n=1+2']) {
+ assert.ok(csvCell(input).startsWith('"\''),input);
+ }
+ assert.equal(csvCell('safe "value",\nnext'),'"safe ""value"",\nnext"');
+ assert.equal(csvCell(null),'""');assert.equal(csvCell(false),'"false"');
+});
+test('CSV contains all filtered rows across pages, export scope, canonical inputs and identifiers',()=>{
+ const csv=submissionsCsv(Array.from({length:12},(_,i)=>({...submission,id:`receipt-${i+1}`})),meta);
+ assert.ok(csv.startsWith('\uFEFF"exported_at"'));assert.ok(csv.endsWith('\r\n'));
+ assert.ok(csv.includes('"receipt-12"'));assert.ok(csv.includes('up to 200'));
+ assert.ok(csv.includes('"auditor"'));assert.ok(csv.includes('"actionstack_security"'));
+ assert.ok(csv.includes('HOST-A'));assert.ok(csv.includes('""targets""'));assert.ok(csv.includes('"21","22"'));
+});
diff --git a/tests/test_pages.py b/tests/test_pages.py
index 499f406..f8d76f7 100644
--- a/tests/test_pages.py
+++ b/tests/test_pages.py
@@ -320,7 +320,7 @@ def test_adapter_uses_saved_certificate_option_and_still_requires_https(self):
with self.assertRaises(Error): Soar({'soar_url':'http://soar.example.test','ignore_certificate_errors':True},'test')
def test_existing_ca_is_loaded_when_validation_enabled(self):
context=Mock()
- with patch('actionstack.soar.ssl.create_default_context',return_value=context):
+ with patch('actionstack.soar.ssl.create_default_context',return_value=context), patch('actionstack.soar.load_system_ca_bundles'):
self.assertIs(validate_ca('saved-pem',False),context)
context.load_verify_locations.assert_called_once_with(cadata='saved-pem')
def test_https_origin_and_ca_required(self):
diff --git a/tests/test_retention.py b/tests/test_retention.py
new file mode 100644
index 0000000..ffea70f
--- /dev/null
+++ b/tests/test_retention.py
@@ -0,0 +1,92 @@
+import json
+import unittest
+from unittest.mock import patch, Mock
+import test_pages as fx
+from actionstack.core import Error, clone
+from actionstack.retention import age_out_submissions
+from actionstack.splunk_store import KVStore
+
+NOW='2026-09-22T12:00:00Z'
+
+class RetentionTests(unittest.TestCase):
+ setUp=fx.ServiceTests.setUp
+ tearDown=fx.ServiceTests.tearDown
+
+ def old_receipt(self):
+ r=self.svc.submit(self.user,fx.ServiceTests.body(self))
+ record=self.store.get('submissions',r['id'])
+ record['updated_at']='2025-01-01T00:00:00Z'
+ self.store.put('submissions',record)
+ return record
+
+ def enable(self,days=30):
+ return self.svc.save_settings(fx.ADMIN,{'soar_url':'https://soar.example.test','revision':self.svc.settings()['revision'],'retention_days':days})
+
+ def test_default_keeps_receipts_and_only_admin_can_change_retention(self):
+ record=self.old_receipt()
+ self.assertEqual(self.svc.public_settings()['retention_days'],0)
+ with patch('actionstack.retention.utcnow',return_value=NOW):age_out_submissions(self.svc)
+ self.assertEqual(self.store.get('submissions',record['id'])['status'],'submitted')
+ with self.assertRaises(Error): self.svc.save_settings(self.user,{'soar_url':'https://soar.example.test','revision':0,'retention_days':7})
+ for value in [True,-1,1,'30',30.0,None]:
+ with self.subTest(value=value),self.assertRaises(Error):self.enable(value)
+
+ def test_old_delivered_receipt_is_compacted_without_touching_soar_or_audit(self):
+ record=self.old_receipt();self.enable()
+ self.factory.reset_mock()
+ with patch('actionstack.retention.utcnow',return_value=NOW):
+ self.assertEqual(self.svc.list_submissions(self.user,{}),[])
+ marker=self.store.get('submissions',record['id'])
+ self.assertEqual(set(marker),{'_key','id','status','expired_at'})
+ self.assertEqual(marker['status'],'expired')
+ self.assertNotIn('192.0.2.42',json.dumps(marker))
+ self.factory.assert_not_called()
+ self.assertEqual(len(self.store.list('remote_container')),1)
+ self.assertEqual(len(self.store.list('remote_artifact')),1)
+ self.assertTrue(any(r['event']=='submission.accepted' for r in self.store.list('audit')))
+ self.assertTrue(any(r['event']=='submissions.expired' for r in self.store.list('audit')))
+ with self.assertRaises(Error) as expired:self.svc.submit(self.user,fx.ServiceTests.body(self))
+ self.assertEqual(expired.exception.status,410)
+ for call in [lambda:self.svc.deliver(self.user,record['id']),lambda:self.svc.activity(self.user,record['id']),lambda:self.svc.dispatch(self.user,'GET','/submissions/'+record['id'])]:
+ with self.assertRaises(Error) as hidden:call()
+ self.assertEqual(hidden.exception.status,404)
+ self.assertEqual(len(self.store.list('remote_container')),1)
+
+ def test_recent_unfinished_locked_and_invalid_timestamps_are_preserved(self):
+ record=self.old_receipt();self.enable()
+ for n,status in enumerate(['pending','submitting','failed','needs_attention']):
+ r=clone(record);r.update(_key=f'unfinished-{n}',id=f'unfinished-{n}',status=status);self.store.insert('submissions',r)
+ for name,at in [('recent','2026-09-21T00:00:00Z'),('boundary','2026-08-23T12:00:00Z'),('invalid','2020-invalid'),('naive','2020-01-01T00:00:00')]:
+ r=clone(record);r.update(_key=name,id=name,updated_at=at);self.store.insert('submissions',r)
+ self.store.insert('locks',{'_key':'delivery:'+record['id']})
+ with patch('actionstack.retention.utcnow',return_value=NOW):age_out_submissions(self.svc)
+ self.assertFalse(any(r['status']=='expired' for r in self.store.list('submissions')))
+
+ def test_cleanup_is_bounded_shared_across_instances_and_settings_omission_preserves_policy(self):
+ record=self.old_receipt();self.enable()
+ for n in range(104):
+ r=clone(record);r.update(_key=f'old-{n:03}',id=f'old-{n:03}');self.store.insert('submissions',r)
+ with patch('actionstack.retention.utcnow',return_value=NOW):
+ age_out_submissions(self.svc)
+ age_out_submissions(self.svc)
+ self.assertEqual(len(self.store.list('submissions',{'status':'expired'})),100)
+ with patch('actionstack.retention.utcnow',return_value='2026-09-22T13:00:00Z'):age_out_submissions(self.svc)
+ self.assertEqual(len(self.store.list('submissions',{'status':'expired'})),105)
+ result=self.svc.save_settings(fx.ADMIN,{'soar_url':'https://soar.example.test','revision':1})
+ self.assertEqual(result['retention_days'],30)
+
+ def test_atomic_replacement_never_deletes_the_idempotency_key(self):
+ record=self.old_receipt();self.enable()
+ with patch.object(self.store,'delete',wraps=self.store.delete) as delete,patch('actionstack.retention.utcnow',return_value=NOW):
+ age_out_submissions(self.svc)
+ delete.assert_not_called()
+ self.assertIsNotNone(self.store.get('submissions',record['id']))
+
+class RetentionStoreTests(unittest.TestCase):
+ def test_kv_query_uses_bounded_limit_and_retention_filter(self):
+ rest=Mock();rest.call.return_value=[{'_key':str(i)} for i in range(100)]
+ query={'status':'submitted','updated_at':{'$lt':'2026-01-01T00:00:00Z'}}
+ self.assertEqual(len(KVStore(rest).list('submissions',query,limit=100)),100)
+ rest.call.assert_called_once()
+ params=rest.call.call_args.kwargs['params']
+ self.assertEqual(params['limit'],100);self.assertEqual(json.loads(params['query']),query)
diff --git a/tests/test_tls_trust.py b/tests/test_tls_trust.py
new file mode 100644
index 0000000..7e554a9
--- /dev/null
+++ b/tests/test_tls_trust.py
@@ -0,0 +1,47 @@
+import os
+import ssl
+import unittest
+from unittest.mock import Mock, patch, call
+from urllib.error import URLError
+import test_pages
+from actionstack.soar import validate_ca,load_system_ca_bundles,Soar
+from actionstack.core import Error
+
+class TrustTests(unittest.TestCase):
+ def test_linux_loads_os_bundle_in_addition_to_python_defaults(self):
+ ctx=Mock()
+ with patch('actionstack.soar.ssl.create_default_context',return_value=ctx),patch('actionstack.soar.sys.platform','linux'),patch.dict(os.environ,{},clear=True),patch('actionstack.soar.os.path.isfile',side_effect=lambda p:p=='/etc/pki/tls/certs/ca-bundle.crt'):
+ self.assertIs(validate_ca('custom-pem'),ctx)
+ ctx.load_verify_locations.assert_has_calls([call(cafile='/etc/pki/tls/certs/ca-bundle.crt'),call(cadata='custom-pem')])
+
+ def test_explicit_ca_override_is_respected_and_unreadable_bundle_does_not_disable_validation(self):
+ ctx=Mock()
+ with patch('actionstack.soar.sys.platform','linux'),patch.dict(os.environ,{'SSL_CERT_FILE':'/explicit/bundle.pem'},clear=True):load_system_ca_bundles(ctx)
+ ctx.load_verify_locations.assert_not_called()
+ with patch('actionstack.soar.sys.platform','linux'),patch.dict(os.environ,{},clear=True),patch('actionstack.soar.os.path.isfile',return_value=True):
+ ctx.load_verify_locations.side_effect=OSError('unreadable')
+ load_system_ca_bundles(ctx)
+ verified=validate_ca('')
+ self.assertEqual(verified.verify_mode,ssl.CERT_REQUIRED);self.assertTrue(verified.check_hostname)
+
+ def test_certificate_failure_is_distinguished_from_connection_timeout(self):
+ remote=Soar({'soar_url':'https://soar.example.test'},'secret-token')
+ exc=ssl.SSLCertVerificationError(1,'certificate verify failed')
+ exc.verify_message='hostname mismatch secret-token'
+ for failure in [exc,URLError(exc)]:
+ remote.opener=Mock();remote.opener.open.side_effect=failure
+ with self.assertRaises(Error) as raised:remote.call('GET','container_options')
+ self.assertEqual(raised.exception.status,502)
+ self.assertIn('certificate verification failed',raised.exception.message)
+ self.assertIn('hostname mismatch',raised.exception.message)
+ self.assertNotIn('secret-token',raised.exception.message)
+ remote.opener.open.side_effect=URLError(TimeoutError())
+ with self.assertRaises(Error) as raised:remote.call('GET','container_options')
+ self.assertEqual(raised.exception.status,504)
+ self.assertNotIn('CA chain',raised.exception.message)
+
+ def test_disabled_validation_does_not_load_trust_or_change_global_defaults(self):
+ with patch('actionstack.soar.load_system_ca_bundles') as load:
+ ctx=validate_ca('',True)
+ load.assert_not_called();self.assertEqual(ctx.verify_mode,ssl.CERT_NONE)
+ self.assertTrue(ssl.create_default_context().check_hostname)