From 3c14ba6fbba19ae9d042c787c22ab701a2f1bd6d Mon Sep 17 00:00:00 2001
From: Steph Milovic
Date: Wed, 26 Aug 2026 14:36:26 -0600
Subject: [PATCH 01/27] feat(threat-intel): RSS, STIX, and TAXII source
adapters
---
.../threat_intel/adapters/fingerprint.test.ts | 73 ++++
.../threat_intel/adapters/fingerprint.ts | 30 ++
.../adapters/rss/parse_rss.test.ts | 154 +++++++
.../threat_intel/adapters/rss/parse_rss.ts | 238 +++++++++++
.../adapters/rss/rss_adapter.test.ts | 187 +++++++++
.../threat_intel/adapters/rss/rss_adapter.ts | 131 ++++++
.../adapters/stix/parse_pattern.test.ts | 383 ++++++++++++++++++
.../adapters/stix/parse_pattern.ts | 213 ++++++++++
.../adapters/stix/split_bundle.test.ts | 103 +++++
.../adapters/stix/split_bundle.ts | 104 +++++
.../adapters/stix/stix_adapter.test.ts | 329 +++++++++++++++
.../adapters/stix/stix_adapter.ts | 162 ++++++++
.../adapters/taxii/taxii_adapter.test.ts | 243 +++++++++++
.../adapters/taxii/taxii_adapter.ts | 214 ++++++++++
.../server/threat_intel/adapters/types.ts | 113 ++++++
15 files changed, 2677 insertions(+)
create mode 100644 x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/fingerprint.test.ts
create mode 100644 x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/fingerprint.ts
create mode 100644 x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/rss/parse_rss.test.ts
create mode 100644 x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/rss/parse_rss.ts
create mode 100644 x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/rss/rss_adapter.test.ts
create mode 100644 x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/rss/rss_adapter.ts
create mode 100644 x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/stix/parse_pattern.test.ts
create mode 100644 x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/stix/parse_pattern.ts
create mode 100644 x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/stix/split_bundle.test.ts
create mode 100644 x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/stix/split_bundle.ts
create mode 100644 x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/stix/stix_adapter.test.ts
create mode 100644 x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/stix/stix_adapter.ts
create mode 100644 x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/taxii/taxii_adapter.test.ts
create mode 100644 x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/taxii/taxii_adapter.ts
create mode 100644 x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/types.ts
diff --git a/x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/fingerprint.test.ts b/x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/fingerprint.test.ts
new file mode 100644
index 0000000000000..c19701426d28e
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/fingerprint.test.ts
@@ -0,0 +1,73 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { createHash } from 'crypto';
+import { buildFingerprint } from './fingerprint';
+
+describe('buildFingerprint', () => {
+ it('length-prefixes each part so the seed is unambiguous', () => {
+ // The seed is `:` per part, concatenated. There is no workflow-side
+ // computation to match: the definitions only pass `content_fingerprint`
+ // through, and the engine has no sha256 Liquid filter, so this encoding is
+ // ours alone and only has to be self-consistent.
+ const expected = createHash('sha256')
+ .update('28:https://example.com/feed.xml7:item-425:Title')
+ .digest('hex');
+ expect(buildFingerprint(['https://example.com/feed.xml', 'item-42', 'Title'])).toBe(expected);
+ });
+
+ // A plain join on `:` collided across part boundaries, so two different feed
+ // items produced one fingerprint and the second was deduplicated away as
+ // already-ingested. Titles, URLs, and ids routinely contain colons.
+ it('does not collide when a colon moves across a part boundary', () => {
+ expect(buildFingerprint(['a:b', 'c'])).not.toBe(buildFingerprint(['a', 'b:c']));
+ });
+
+ it('does not collide when a part boundary shifts', () => {
+ expect(buildFingerprint(['https://evil.test', 'a'])).not.toBe(
+ buildFingerprint(['https://evil.test:a', ''])
+ );
+ });
+
+ it('returns a 64-char hex digest', () => {
+ expect(buildFingerprint(['a', 'b'])).toMatch(/^[0-9a-f]{64}$/);
+ });
+
+ it('NFKC-normalizes parts so unicode equivalents collapse', () => {
+ // U+FB01 (fi ligature) vs ASCII "fi".
+ expect(buildFingerprint(['url', 'id', '\ufb01nal'])).toBe(
+ buildFingerprint(['url', 'id', 'final'])
+ );
+ });
+
+ it('trims leading/trailing whitespace per part', () => {
+ expect(buildFingerprint([' url ', ' id '])).toBe(buildFingerprint(['url', 'id']));
+ });
+
+ it('treats undefined/null parts as empty strings (still positional)', () => {
+ // Two leading missing parts are still part of the seed shape — a
+ // re-fetch with the same shape produces the same digest.
+ const a = buildFingerprint([undefined, undefined, 'id']);
+ const b = buildFingerprint([undefined, undefined, 'id']);
+ expect(a).toBe(b);
+ // …but a missing part is *positionally distinct* from the part
+ // moving up by one — `:::id` and `id` produce different digests.
+ expect(a).not.toBe(buildFingerprint(['id']));
+ });
+
+ it('produces a stable digest for the same logical input', () => {
+ const fp1 = buildFingerprint(['https://example.com', 'id-1', 'modified-2026-05-01']);
+ const fp2 = buildFingerprint(['https://example.com', 'id-1', 'modified-2026-05-01']);
+ expect(fp1).toBe(fp2);
+ });
+
+ it('produces different digests when the version stamp changes', () => {
+ const a = buildFingerprint(['https://example.com', 'id-1', '2026-05-01']);
+ const b = buildFingerprint(['https://example.com', 'id-1', '2026-05-02']);
+ expect(a).not.toBe(b);
+ });
+});
diff --git a/x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/fingerprint.ts b/x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/fingerprint.ts
new file mode 100644
index 0000000000000..bb7a7e3f31564
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/fingerprint.ts
@@ -0,0 +1,30 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { createHash } from 'crypto';
+
+/**
+ * SHA-256 over NFKC-normalized parts, each length-prefixed.
+ *
+ * A plain `join(':')` is ambiguous: `['a:b', 'c']` and `['a', 'b:c']` produce the
+ * same seed, so two different items collide on one fingerprint and the second is
+ * deduplicated away as though it had already been ingested. The parts here are
+ * feed-controlled titles, URLs, and ids, which routinely contain colons, so this
+ * is reachable rather than theoretical.
+ *
+ * Length-prefixing makes the seed unambiguous. Nothing outside this module
+ * recomputes these values (the workflows only pass `content_fingerprint` through),
+ * so the change is safe; already-stored reports will be re-ingested once under
+ * their new fingerprint.
+ */
+export const buildFingerprint = (parts: ReadonlyArray): string => {
+ const seed = parts
+ .map((part) => (part ?? '').trim().normalize('NFKC'))
+ .map((part) => `${part.length}:${part}`)
+ .join('');
+ return createHash('sha256').update(seed).digest('hex');
+};
diff --git a/x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/rss/parse_rss.test.ts b/x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/rss/parse_rss.test.ts
new file mode 100644
index 0000000000000..da2751823c2c1
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/rss/parse_rss.test.ts
@@ -0,0 +1,154 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { parseRssFeed } from './parse_rss';
+
+const RSS2 = `
+
+
+ Acme Threat Research
+ en-US
+
+ APT-1 campaign
+ https://acme.example/posts/apt1
+ acme:apt1
+ Mon, 12 May 2025 09:30:00 GMT
+ Brief summary of APT-1.
]]>
+
+
+ Ransomware uptick
+ https://acme.example/posts/ransom
+ Tue, 13 May 2025 10:00:00 GMT
+ Plain summary.
+
+
+
+ Orphan item with no id, link, or guid.
+
+
+`;
+
+const ATOM = `
+
+ Vendor Labs
+
+ tag:vendor.example,2025:post-1
+ Post one
+
+ 2025-05-12T09:30:00Z
+ Short summary.
+ <p>Long body</p>
+
+
+ tag:vendor.example,2025:post-2
+ Post two
+
+ 2025-05-11T08:00:00Z
+ Only a summary.
+
+`;
+
+const RDF = `
+
+
+ RDF Feed
+ en
+
+
+ RDF Item
+ https://example.com/posts/1
+ RDF body.
+ 2025-05-12T09:30:00Z
+
+`;
+
+describe('parseRssFeed', () => {
+ it('returns an empty result for an empty input', async () => {
+ const parsed = await parseRssFeed('');
+ expect(parsed).toEqual({ feedTitle: '', entries: [] });
+ });
+
+ it('parses an RSS 2.0 feed and drops items without an identifier', async () => {
+ const parsed = await parseRssFeed(RSS2);
+ expect(parsed.feedTitle).toBe('Acme Threat Research');
+ expect(parsed.language).toBe('en');
+ expect(parsed.entries).toHaveLength(2);
+ expect(parsed.entries[0]).toMatchObject({
+ id: 'acme:apt1',
+ title: 'APT-1 campaign',
+ link: 'https://acme.example/posts/apt1',
+ publishedAt: new Date('Mon, 12 May 2025 09:30:00 GMT').toISOString(),
+ });
+ // CDATA contents preserved as-is on bodyHtml.
+ expect(parsed.entries[0].bodyHtml).toContain('APT-1');
+ });
+
+ it('parses an Atom feed and prefers updated over published', async () => {
+ const parsed = await parseRssFeed(ATOM);
+ expect(parsed.feedTitle).toBe('Vendor Labs');
+ expect(parsed.language).toBe('en');
+ expect(parsed.entries).toHaveLength(2);
+ expect(parsed.entries[0].publishedAt).toBe('2025-05-12T09:30:00.000Z');
+ expect(parsed.entries[0].link).toBe('https://vendor.example/post-1');
+ expect(parsed.entries[0].bodyHtml).toBe('
Long body
');
+ expect(parsed.entries[1].publishedAt).toBe('2025-05-11T08:00:00.000Z');
+ });
+
+ it('parses an RDF / RSS 1.0 feed using rdf:about as the id', async () => {
+ const parsed = await parseRssFeed(RDF);
+ expect(parsed.feedTitle).toBe('RDF Feed');
+ expect(parsed.language).toBe('en');
+ expect(parsed.entries).toHaveLength(1);
+ expect(parsed.entries[0]).toMatchObject({
+ id: 'https://example.com/posts/1',
+ title: 'RDF Item',
+ link: 'https://example.com/posts/1',
+ publishedAt: '2025-05-12T09:30:00.000Z',
+ });
+ });
+
+ it('returns an empty result for an unrecognized root element', async () => {
+ const parsed = await parseRssFeed('');
+ expect(parsed).toEqual({ feedTitle: '', entries: [] });
+ });
+});
+
+// RSS 2.0 permits an item with a description and no title, and real advisory feeds
+// publish them. Every parser branch leaves `body` empty and puts the description in
+// `bodyHtml`, so a `title || body` check dropped all of them.
+describe('parseRssFeed — description-only items', () => {
+ it('keeps an RSS 2.0 item that has a description but no title', async () => {
+ const feed = `
+
+
+ Advisories
+
+ adv-1
+ Threat actor deployed ransomware via 185.220.101.45.
+
+
+`;
+
+ const parsed = await parseRssFeed(feed);
+
+ expect(parsed.entries).toHaveLength(1);
+ expect(parsed.entries[0].id).toBe('adv-1');
+ expect(parsed.entries[0].bodyHtml).toContain('ransomware');
+ });
+
+ it('still drops an item with no identifier', async () => {
+ const feed = `
+
+
+ Advisories
+ No guid and no link.
+
+`;
+
+ expect((await parseRssFeed(feed)).entries).toHaveLength(0);
+ });
+});
diff --git a/x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/rss/parse_rss.ts b/x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/rss/parse_rss.ts
new file mode 100644
index 0000000000000..189074634d420
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/rss/parse_rss.ts
@@ -0,0 +1,238 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import xml2js from 'xml2js';
+
+/**
+ * Format-agnostic representation of a single feed entry. The RSS
+ * adapter emits one `NormalizedReport` per `RssEntry` regardless of
+ * whether the upstream feed is RSS 2.0, Atom, or RDF — keeping the
+ * normalization step format-agnostic means the adapter doesn't have
+ * to branch on the feed family at every step.
+ *
+ * `id` is the most stable identifier the feed exposes (Atom ``,
+ * RSS ``, falling back to `` when neither is present).
+ * It's used by the adapter to seed the per-item `content_fingerprint`
+ * and to populate `lineage.source_doc_ref.id`. If we end up with
+ * two items that share the same `id` *and* the same title, the
+ * fingerprint will collapse them — which is what we want for
+ * RSS-syndicated copies of the same advisory.
+ */
+export interface RssEntry {
+ id: string;
+ title: string;
+ link?: string;
+ /** ISO-8601 string when the feed exposes a publish/update timestamp. */
+ publishedAt?: string;
+ /**
+ * Best-effort plaintext body. The adapter strips HTML before writing
+ * to `content.body_text`; the original markup is preserved on
+ * `content.body_html` (mapped `index: false`) so extraction can be re-run
+ * without re-fetching. It is unsanitized feed markup and must not be rendered.
+ */
+ body: string;
+ /** Original markup preserved verbatim. */
+ bodyHtml?: string;
+}
+
+export interface ParsedFeed {
+ /** Normalized feed-level title. Empty string when the feed declares no title. */
+ feedTitle: string;
+ /** Lowercased ISO-639-1 if the feed declares one (`` / `xml:lang`). */
+ language?: string;
+ entries: RssEntry[];
+}
+
+/**
+ * Parse an RSS 2.0, Atom, or RDF feed.
+ *
+ * Tolerant by design — feeds in the wild violate every spec at least
+ * sometimes, so we treat the structure as advisory and collect what
+ * we can. Items missing every identifying field (no `id`, `guid`, or
+ * `link`) are dropped because there's no stable seed for the
+ * fingerprint and including them would just create one new row per
+ * run forever.
+ */
+export const parseRssFeed = async (xml: string): Promise => {
+ const trimmed = xml.trim();
+ if (!trimmed) return { feedTitle: '', entries: [] };
+
+ // `explicitArray: true` matches the existing siem_migrations XmlParser
+ // convention so all child accessors are arrays — which simplifies the
+ // walk below (no `Array.isArray` branches per field).
+ const parsed = await xml2js.parseStringPromise(trimmed, {
+ explicitArray: true,
+ explicitCharkey: true,
+ charkey: '_',
+ attrkey: '$',
+ trim: false,
+ normalizeTags: false,
+ });
+
+ // Atom:
+ if (parsed.feed) return parseAtom(parsed.feed);
+ // RSS 2.0: ...
+ if (parsed.rss?.channel?.[0]) return parseRss2(parsed.rss.channel[0]);
+ // RDF / RSS 1.0: ...
+ const rdfRoot = parsed['rdf:RDF'] ?? parsed.RDF ?? parsed['rss:RDF'] ?? parsed.feed ?? undefined;
+ if (rdfRoot) return parseRdf(rdfRoot);
+
+ return { feedTitle: '', entries: [] };
+};
+
+interface XmlNode {
+ $?: Record;
+ _?: string;
+ [key: string]: unknown;
+}
+
+/** Best-effort string extraction from xml2js's nested array+object shape. */
+const text = (node: unknown): string => {
+ if (node == null) return '';
+ if (typeof node === 'string') return node;
+ if (Array.isArray(node)) return text(node[0]);
+ if (typeof node === 'object') {
+ const obj = node as XmlNode;
+ if (typeof obj._ === 'string') return obj._;
+ // Atom uses an attribute-only shape.
+ if (obj.$ && typeof obj.$.href === 'string') return obj.$.href;
+ }
+ return '';
+};
+
+const firstAttr = (node: unknown, attr: string): string | undefined => {
+ if (Array.isArray(node)) return firstAttr(node[0], attr);
+ if (node && typeof node === 'object') {
+ const obj = node as XmlNode;
+ return obj.$?.[attr];
+ }
+ return undefined;
+};
+
+const toIsoDate = (raw: string | undefined): string | undefined => {
+ if (!raw) return undefined;
+ const trimmed = raw.trim();
+ if (!trimmed) return undefined;
+ const ms = Date.parse(trimmed);
+ return Number.isFinite(ms) ? new Date(ms).toISOString() : undefined;
+};
+
+const pickLanguage = (raw: string | undefined): string | undefined => {
+ if (!raw) return undefined;
+ // `en-US` / `en_us` → `en`. Lowercasing matches ECS' `language`
+ // field convention used by downstream mappers.
+ const head = raw.split(/[-_]/)[0];
+ return head ? head.toLowerCase() : undefined;
+};
+
+const dropEmpty = (entries: RssEntry[]): RssEntry[] =>
+ // Items without any identifier would generate a fresh fingerprint on
+ // every run, defeating the dedup gate. Better to drop them than to
+ // pollute the reports index.
+ //
+ // `bodyHtml` has to be in the content check. Every parser branch below leaves
+ // `body` as an empty string and puts the actual description in `bodyHtml`, so
+ // testing `title || body` dropped every RSS 2.0 item that had a description but
+ // no title, which the spec permits and real advisory feeds do publish.
+ entries.filter((entry) => entry.id && (entry.title || entry.body || entry.bodyHtml));
+
+const parseRss2 = (channel: XmlNode): ParsedFeed => {
+ const items = (channel.item as XmlNode[] | undefined) ?? [];
+ const entries: RssEntry[] = items.map((item) => {
+ const guid = text(item.guid);
+ const link = text(item.link);
+ const id = guid || link || text(item.id);
+ const title = text(item.title);
+ const description = text(item.description);
+ // ships the full HTML article body when the
+ // feed wants to provide more than the summary. Some feeds use it,
+ // some don't — fall back to the description.
+ const contentEncoded = text((item as XmlNode)['content:encoded']);
+ const bodyHtml = contentEncoded || description || undefined;
+ const publishedAt =
+ toIsoDate(text(item.pubDate)) ?? toIsoDate(text((item as XmlNode)['dc:date']));
+ return {
+ id,
+ title,
+ link: link || undefined,
+ publishedAt,
+ body: '',
+ bodyHtml,
+ };
+ });
+ return {
+ feedTitle: text(channel.title),
+ language: pickLanguage(text(channel.language)),
+ entries: dropEmpty(entries),
+ };
+};
+
+const parseAtom = (feed: XmlNode): ParsedFeed => {
+ const entriesRaw = (feed.entry as XmlNode[] | undefined) ?? [];
+ const entries: RssEntry[] = entriesRaw.map((entry) => {
+ const id = text(entry.id);
+ const title = text(entry.title);
+ // Atom links can be one or many; we want the first `rel="alternate"`
+ // (or the one without a rel attribute, which is how most feeds ship
+ // a single canonical link).
+ const linkArr = (entry.link as XmlNode[] | undefined) ?? [];
+ const link =
+ linkArr.find((l) => {
+ const rel = firstAttr(l, 'rel');
+ return rel === undefined || rel === 'alternate';
+ }) ?? linkArr[0];
+ const linkHref = firstAttr(link, 'href') ?? text(link);
+ const summary = text(entry.summary);
+ const content = text(entry.content);
+ const bodyHtml = content || summary || undefined;
+ const publishedAt = toIsoDate(text(entry.updated)) ?? toIsoDate(text(entry.published));
+ return {
+ id: id || linkHref,
+ title,
+ link: linkHref || undefined,
+ publishedAt,
+ body: '',
+ bodyHtml,
+ };
+ });
+ return {
+ feedTitle: text(feed.title),
+ language: pickLanguage(firstAttr(feed, 'xml:lang')),
+ entries: dropEmpty(entries),
+ };
+};
+
+const parseRdf = (rdf: XmlNode): ParsedFeed => {
+ // RSS 1.0 puts items as siblings of at the RDF root rather
+ // than nested under it; the channel's `` only references
+ // them by `rdf:about`. We don't need the order — we just walk the
+ // siblings.
+ const channel = ((rdf.channel as XmlNode[] | undefined) ?? [])[0] ?? {};
+ const items = (rdf.item as XmlNode[] | undefined) ?? [];
+ const entries: RssEntry[] = items.map((item) => {
+ const about = firstAttr(item, 'rdf:about') ?? firstAttr(item, 'about');
+ const link = text(item.link);
+ const id = about || link || text(item.guid);
+ const title = text(item.title);
+ const description = text(item.description);
+ const publishedAt =
+ toIsoDate(text((item as XmlNode)['dc:date'])) ?? toIsoDate(text(item.pubDate));
+ return {
+ id,
+ title,
+ link: link || undefined,
+ publishedAt,
+ body: '',
+ bodyHtml: description || undefined,
+ };
+ });
+ return {
+ feedTitle: text(channel.title),
+ language: pickLanguage(text((channel as XmlNode)['dc:language']) || text(channel.language)),
+ entries: dropEmpty(entries),
+ };
+};
diff --git a/x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/rss/rss_adapter.test.ts b/x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/rss/rss_adapter.test.ts
new file mode 100644
index 0000000000000..002fd0ff43b92
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/rss/rss_adapter.test.ts
@@ -0,0 +1,187 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { elasticsearchServiceMock, loggingSystemMock } from '@kbn/core/server/mocks';
+import { rssAdapter } from './rss_adapter';
+import type { AdapterRunContext, SourceHit } from '../types';
+
+const FEED_URL = 'https://acme.example/feed.xml';
+const FEED_BODY = `
+
+ Acme
+
+ Item one
+ acme:1
+ https://acme.example/1
+ Mon, 12 May 2025 09:30:00 GMT
+ Body one]]>
+
+
+ Item two
+ acme:2
+ https://acme.example/2
+ Tue, 13 May 2025 09:30:00 GMT
+ Body two
+
+`;
+
+const buildSource = (overrides: Partial = {}): SourceHit => ({
+ _id: 'rss:acme',
+ _source: {
+ adapter_type: 'rss',
+ name: 'Acme',
+ config: { url: FEED_URL },
+ ...overrides,
+ },
+});
+
+const NOW = new Date('2026-05-16T12:00:00.000Z');
+
+const buildContext = (
+ fetchImpl: jest.Mock, [string | URL | Request, RequestInit?]>
+): AdapterRunContext => ({
+ esClient: elasticsearchServiceMock.createElasticsearchClient(),
+ logger: loggingSystemMock.createLogger(),
+ abortSignal: new AbortController().signal,
+ now: () => NOW,
+ fetchFn: fetchImpl as unknown as typeof fetch,
+ lookupFn: async () => [{ address: '93.184.216.34' }],
+});
+
+const okResponse = (body: string): Response =>
+ new Response(body, {
+ status: 200,
+ statusText: 'OK',
+ headers: { 'Content-Type': 'application/rss+xml' },
+ });
+
+describe('rssAdapter', () => {
+ it('emits one normalized report per RSS item', async () => {
+ const fetchMock = jest.fn().mockResolvedValue(okResponse(FEED_BODY));
+ const reports = await rssAdapter.run(buildSource(), buildContext(fetchMock));
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(fetchMock.mock.calls[0][0]).toBe(FEED_URL);
+
+ expect(reports).toHaveLength(2);
+ expect(reports[0]).toMatchObject({
+ '@timestamp': NOW.toISOString(),
+ space_id: '*',
+ source: {
+ type: 'rss',
+ name: 'Acme',
+ url: 'https://acme.example/1',
+ adapter_id: 'rss:rss:acme',
+ },
+ content: {
+ title: 'Item one',
+ body_text: 'Body one',
+ body_html: '
Body one
',
+ language: 'en',
+ },
+ severity: { level: 'medium', score: 40 },
+ lineage: {
+ ingested_at: NOW.toISOString(),
+ extraction_method: 'pending',
+ source_doc_ref: { index: 'rss:feed', id: 'acme:1' },
+ },
+ });
+ // Fingerprint must be a stable 64-hex digest (matches buildFingerprint).
+ expect(reports[0].content_fingerprint).toMatch(/^[0-9a-f]{64}$/);
+ expect(reports[0].content_fingerprint).not.toBe(reports[1].content_fingerprint);
+ });
+
+ // The fingerprint used to be feed URL + guid + title only, so an advisory
+ // that kept its guid and title while revising the text deduped away forever.
+ describe('revision detection', () => {
+ const feedWith = ({ body = 'Body one', pubDate = 'Mon, 12 May 2025 09:30:00 GMT' } = {}) =>
+ `
+
+ Acme
+
+ Item one
+ acme:1
+ https://acme.example/1
+ ${pubDate}
+ ${body}
+
+`;
+
+ const fingerprintFor = async (feed: string) => {
+ const reports = await rssAdapter.run(
+ buildSource(),
+ buildContext(jest.fn().mockResolvedValue(okResponse(feed)))
+ );
+ return reports[0].content_fingerprint;
+ };
+
+ it('is stable when the item is unchanged', async () => {
+ expect(await fingerprintFor(feedWith())).toBe(await fingerprintFor(feedWith()));
+ });
+
+ it('changes when the body is revised under the same guid and title', async () => {
+ expect(await fingerprintFor(feedWith({ body: 'Revised body with new IOCs' }))).not.toBe(
+ await fingerprintFor(feedWith())
+ );
+ });
+
+ it('changes when only the publish timestamp moves', async () => {
+ expect(await fingerprintFor(feedWith({ pubDate: 'Wed, 14 May 2025 09:30:00 GMT' }))).not.toBe(
+ await fingerprintFor(feedWith())
+ );
+ });
+ });
+
+ it('stamps space_id from the source when set', async () => {
+ const fetchMock = jest.fn().mockResolvedValue(okResponse(FEED_BODY));
+ const reports = await rssAdapter.run(
+ buildSource({ space_id: 'team-a' }),
+ buildContext(fetchMock)
+ );
+ expect(reports[0].space_id).toBe('team-a');
+ });
+
+ it('returns [] when the source has no config.url', async () => {
+ const fetchMock = jest.fn();
+ const reports = await rssAdapter.run(
+ buildSource({ config: {} as Record }),
+ buildContext(fetchMock)
+ );
+ expect(reports).toEqual([]);
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it('throws on a non-2xx response so the workflow surfaces the failure', async () => {
+ const fetchMock = jest
+ .fn()
+ .mockResolvedValue(new Response('boom', { status: 503, statusText: 'Service Unavailable' }));
+ await expect(rssAdapter.run(buildSource(), buildContext(fetchMock))).rejects.toThrow(
+ /HTTP 503/
+ );
+ });
+
+ it('returns [] when the feed contains no parseable items', async () => {
+ const empty = `Empty`;
+ const fetchMock = jest.fn().mockResolvedValue(okResponse(empty));
+ const reports = await rssAdapter.run(buildSource(), buildContext(fetchMock));
+ expect(reports).toEqual([]);
+ });
+
+ it('ingests percent-encoded data: fixture URLs without calling fetch', async () => {
+ const dataUrl = `data:application/rss+xml;charset=utf-8,${encodeURIComponent(FEED_BODY)}`;
+ const fetchMock = jest.fn();
+ const reports = await rssAdapter.run(
+ buildSource({ config: { url: dataUrl } }),
+ buildContext(fetchMock)
+ );
+
+ expect(fetchMock).not.toHaveBeenCalled();
+ expect(reports).toHaveLength(2);
+ expect(reports[0].content.title).toBe('Item one');
+ expect(reports[0].lineage.extraction_method).toBe('pending');
+ });
+});
diff --git a/x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/rss/rss_adapter.ts b/x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/rss/rss_adapter.ts
new file mode 100644
index 0000000000000..ee44acdbe2b92
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/rss/rss_adapter.ts
@@ -0,0 +1,131 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { GLOBAL_SPACE_ID } from '../../../../common/threat_intel';
+import { fetchUrlForContext, redactUrl } from '../http_client';
+import { buildFingerprint } from '../fingerprint';
+import { DEFAULT_SEVERITY_LEVEL, DEFAULT_SEVERITY_SCORE } from '../../content/severity';
+import { buildReportContent, collapseWhitespace, stripHtml, truncate } from '../../content/text';
+import type { AdapterRunContext, FetchAdapter, NormalizedReport, SourceHit } from '../types';
+import { decodeDataUrl, isDataUrl } from './decode_data_url';
+import { parseRssFeed } from './parse_rss';
+
+const TITLE_MAX_LENGTH = 280;
+const BODY_TEXT_MAX_LENGTH = 32_000;
+const SOURCE_DOC_REF_INDEX = 'rss:feed';
+
+const readFeedUrl = (source: SourceHit): string | undefined => {
+ const url = source._source.config.url;
+ return typeof url === 'string' && url.length > 0 ? url : undefined;
+};
+
+/**
+ * Resolve the RSS/Atom body for a source URL.
+ *
+ * `data:` URLs are decoded in-process (used by the security_solution data
+ * generator fixtures). Network URLs go through `fetchUrl`, which enforces
+ * the http/https SSRF guard.
+ */
+const readFeedBody = async (feedUrl: string, context: AdapterRunContext): Promise => {
+ if (isDataUrl(feedUrl)) {
+ return decodeDataUrl(feedUrl);
+ }
+
+ const response = await fetchUrlForContext(context)(feedUrl, {
+ abortSignal: context.abortSignal,
+ headers: { Accept: 'application/rss+xml, application/atom+xml, application/xml, text/xml' },
+ });
+
+ if (response.status >= 400) {
+ // Surface as a thrown error so the step's `on-failure: continue: true`
+ // still records the failure on the step result. Returning `[]`
+ // would silently mask broken feeds.
+ throw new Error(
+ `RSS fetch ${redactUrl(feedUrl)} failed: HTTP ${response.status} ${response.statusText}`
+ );
+ }
+
+ return response.body;
+};
+
+export const rssAdapter: FetchAdapter = {
+ adapterType: 'rss',
+ async run(source, context: AdapterRunContext) {
+ const log = context.logger.get('rss-adapter');
+ const feedUrl = readFeedUrl(source);
+ if (!feedUrl) {
+ log.warn(`Source ${source._id} has no config.url — skipping`);
+ return [];
+ }
+
+ const feedBody = await readFeedBody(feedUrl, context);
+ const parsed = await parseRssFeed(feedBody);
+ if (parsed.entries.length === 0) {
+ log.debug(`RSS feed ${feedUrl} returned 0 items for source ${source._id}`);
+ return [];
+ }
+
+ const ingestedAt = context.now().toISOString();
+ const spaceId = source._source.space_id ?? GLOBAL_SPACE_ID;
+ const language = parsed.language ?? 'en';
+ const adapterId = `rss:${source._id}`;
+
+ const reports: NormalizedReport[] = [];
+ for (const entry of parsed.entries) {
+ const title = collapseWhitespace(entry.title || parsed.feedTitle || source._source.name);
+ // Keep the untruncated text for the fingerprint so a revision that only
+ // differs past the stored-body cap is still detected as a change.
+ const fullBodyText = stripHtml(entry.bodyHtml ?? '');
+ const bodyText = truncate(fullBodyText, BODY_TEXT_MAX_LENGTH);
+ // Per-item fingerprint seed: feed URL + stable item id + canonical title,
+ // plus the publish timestamp and a hash of the body. Advisories commonly
+ // keep their `` and title while revising the text and IOCs, so
+ // identity alone would dedup the revision away forever. Including the
+ // body means a re-fetch of the unchanged item still collapses to one
+ // fingerprint, while a revised item produces a fresh row for
+ // `enrich_threat_report` to re-extract over.
+ const fingerprint = buildFingerprint([
+ feedUrl,
+ entry.id,
+ title,
+ entry.publishedAt,
+ fullBodyText,
+ ]);
+ reports.push({
+ '@timestamp': ingestedAt,
+ content_fingerprint: fingerprint,
+ space_id: spaceId,
+ source: {
+ type: 'rss',
+ name: source._source.name,
+ url: entry.link ?? feedUrl,
+ adapter_id: adapterId,
+ },
+ content: buildReportContent({
+ title: truncate(title, TITLE_MAX_LENGTH),
+ bodyText,
+ bodyHtml: entry.bodyHtml,
+ language,
+ }),
+ severity: {
+ level: DEFAULT_SEVERITY_LEVEL,
+ score: DEFAULT_SEVERITY_SCORE,
+ },
+ lineage: {
+ ingested_at: ingestedAt,
+ extraction_method: 'pending',
+ source_doc_ref: {
+ index: SOURCE_DOC_REF_INDEX,
+ id: entry.id,
+ },
+ },
+ });
+ }
+
+ return reports;
+ },
+};
diff --git a/x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/stix/parse_pattern.test.ts b/x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/stix/parse_pattern.test.ts
new file mode 100644
index 0000000000000..a1b0d207106bf
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/stix/parse_pattern.test.ts
@@ -0,0 +1,383 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { parseStixPattern } from './parse_pattern';
+import type { ExtractedIoc } from '../../services/extract_iocs';
+
+// Minimal structural check — all required ExtractedIoc fields present.
+const hasRequiredFields = (ioc: ExtractedIoc): boolean =>
+ typeof ioc.type === 'string' &&
+ typeof ioc.value === 'string' &&
+ typeof ioc.tier === 'string' &&
+ typeof ioc.tier_heuristic === 'string' &&
+ typeof ioc.tier_basis === 'string';
+
+// ── Single-comparison patterns ───────────────────────────────────────────────
+
+describe('parseStixPattern — single-comparison patterns', () => {
+ it('parses ipv4-addr:value', () => {
+ const result = parseStixPattern("[ipv4-addr:value = '1.2.3.4']");
+ expect(result).toHaveLength(1);
+ expect(result[0]).toMatchObject({ type: 'ip', value: '1.2.3.4' });
+ });
+
+ it('parses ipv6-addr:value', () => {
+ const result = parseStixPattern("[ipv6-addr:value = '2001:db8::1']");
+ expect(result).toHaveLength(1);
+ expect(result[0]).toMatchObject({ type: 'ip', value: '2001:db8::1' });
+ });
+
+ it('parses domain-name:value', () => {
+ const result = parseStixPattern("[domain-name:value = 'evil.example.com']");
+ expect(result).toHaveLength(1);
+ expect(result[0]).toMatchObject({ type: 'domain', value: 'evil.example.com' });
+ });
+
+ it('parses url:value', () => {
+ const result = parseStixPattern("[url:value = 'https://evil.example.com/payload']");
+ expect(result).toHaveLength(1);
+ expect(result[0]).toMatchObject({ type: 'url', value: 'https://evil.example.com/payload' });
+ });
+
+ it('parses email-addr:value', () => {
+ const result = parseStixPattern("[email-addr:value = 'attacker@evil.example.com']");
+ expect(result).toHaveLength(1);
+ expect(result[0]).toMatchObject({ type: 'email', value: 'attacker@evil.example.com' });
+ });
+});
+
+// ── Hash variants ────────────────────────────────────────────────────────────
+
+describe('parseStixPattern — hash variants', () => {
+ it("parses file:hashes.'MD5' (quoted)", () => {
+ const result = parseStixPattern("[file:hashes.'MD5' = 'aabbccddeeff00112233445566778899']");
+ expect(result).toHaveLength(1);
+ expect(result[0]).toMatchObject({ type: 'hash', value: 'aabbccddeeff00112233445566778899' });
+ });
+
+ it('parses file:hashes.MD5 (unquoted)', () => {
+ const result = parseStixPattern("[file:hashes.MD5 = 'AABBCCDDEEFF00112233445566778899']");
+ expect(result).toHaveLength(1);
+ expect(result[0]).toMatchObject({ type: 'hash', value: 'aabbccddeeff00112233445566778899' });
+ });
+
+ it("parses file:hashes.'SHA-1' (quoted, hyphenated)", () => {
+ const result = parseStixPattern(
+ "[file:hashes.'SHA-1' = 'da39a3ee5e6b4b0d3255bfef95601890afd80709']"
+ );
+ expect(result).toHaveLength(1);
+ expect(result[0]).toMatchObject({
+ type: 'hash',
+ value: 'da39a3ee5e6b4b0d3255bfef95601890afd80709',
+ });
+ });
+
+ it("parses file:hashes.'SHA-256' (quoted, hyphenated)", () => {
+ const result = parseStixPattern(
+ "[file:hashes.'SHA-256' = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855']"
+ );
+ expect(result).toHaveLength(1);
+ expect(result[0]).toMatchObject({
+ type: 'hash',
+ value: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
+ });
+ });
+
+ it('parses file:hashes.SHA256 (no hyphen, no quotes) and lowercases', () => {
+ const result = parseStixPattern(
+ "[file:hashes.SHA256 = 'E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855']"
+ );
+ expect(result).toHaveLength(1);
+ expect(result[0]).toMatchObject({
+ type: 'hash',
+ value: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
+ });
+ });
+
+ it('parses file:hashes.SHA1 (no hyphen)', () => {
+ const result = parseStixPattern(
+ "[file:hashes.SHA1 = 'da39a3ee5e6b4b0d3255bfef95601890afd80709']"
+ );
+ expect(result).toHaveLength(1);
+ expect(result[0]).toMatchObject({ type: 'hash' });
+ });
+});
+
+// ── Tiering ──────────────────────────────────────────────────────────────────
+
+describe('parseStixPattern — tiering', () => {
+ it('assigns discriminating tier to hashes', () => {
+ const [ioc] = parseStixPattern("[file:hashes.MD5 = 'aabbccddeeff00112233445566778899']");
+ expect(ioc.tier).toBe('discriminating');
+ expect(ioc.tier_heuristic).toBe('discriminating');
+ expect(ioc.tier_basis).toBe('stix_pattern');
+ });
+
+ it('assigns contextual tier to ip/domain/url/email', () => {
+ const patterns = [
+ "[ipv4-addr:value = '1.2.3.4']",
+ "[domain-name:value = 'evil.example.com']",
+ "[url:value = 'https://evil.example.com/']",
+ "[email-addr:value = 'x@evil.example.com']",
+ ];
+ for (const p of patterns) {
+ const [ioc] = parseStixPattern(p);
+ expect(ioc.tier).toBe('contextual');
+ expect(ioc.tier_heuristic).toBe('contextual');
+ expect(ioc.tier_basis).toBe('stix_pattern');
+ }
+ });
+
+ it('sets tier === tier_heuristic (no reassignment)', () => {
+ const [ioc] = parseStixPattern("[ipv4-addr:value = '8.8.8.8']");
+ expect(ioc.tier).toBe(ioc.tier_heuristic);
+ });
+});
+
+// ── Multi-comparison patterns ─────────────────────────────────────────────────
+
+describe('parseStixPattern — multi-comparison patterns', () => {
+ it('extracts all IOCs from an OR pattern', () => {
+ const result = parseStixPattern(
+ "[ipv4-addr:value = '1.2.3.4'] OR [domain-name:value = 'evil.example.com']"
+ );
+ expect(result).toHaveLength(2);
+ expect(result.map((r) => r.type).sort()).toEqual(['domain', 'ip']);
+ });
+
+ it('extracts all IOCs from a bracketed AND pattern', () => {
+ const result = parseStixPattern(
+ "[ipv4-addr:value = '1.2.3.4' AND domain-name:value = 'evil.example.com']"
+ );
+ expect(result).toHaveLength(2);
+ });
+
+ it('extracts all supported IOCs from a mixed-type pattern', () => {
+ const result = parseStixPattern(
+ "[ipv4-addr:value = '1.2.3.4'] OR [file:hashes.MD5 = 'aabbccddeeff00112233445566778899'] OR [domain-name:value = 'c2.example.com']"
+ );
+ expect(result).toHaveLength(3);
+ expect(result.map((r) => r.type).sort()).toEqual(['domain', 'hash', 'ip']);
+ });
+
+ it('deduplicates identical (type, value) pairs within one pattern', () => {
+ const result = parseStixPattern(
+ "[ipv4-addr:value = '1.2.3.4'] OR [ipv4-addr:value = '1.2.3.4']"
+ );
+ expect(result).toHaveLength(1);
+ });
+
+ it('dedup is case-insensitive for hash values', () => {
+ const result = parseStixPattern(
+ "[file:hashes.MD5 = 'AABB1122CCDD3344EEFF5566778899AA'] OR [file:hashes.MD5 = 'aabb1122ccdd3344eeff5566778899aa']"
+ );
+ // Both canonicalize to the same lowercase value → one record
+ expect(result).toHaveLength(1);
+ });
+});
+
+// ── Rejection cases ───────────────────────────────────────────────────────────
+
+describe('parseStixPattern — rejection cases', () => {
+ it('returns [] for yara pattern_type', () => {
+ expect(parseStixPattern("[file:hashes.MD5 = 'aabb1122']", 'yara')).toEqual([]);
+ });
+
+ it('returns [] for snort pattern_type', () => {
+ expect(parseStixPattern('alert tcp ...', 'snort')).toEqual([]);
+ });
+
+ it('returns [] for sigma pattern_type', () => {
+ expect(parseStixPattern('title: Suspicious ...', 'sigma')).toEqual([]);
+ });
+
+ it('returns [] for pcre pattern_type', () => {
+ expect(parseStixPattern('/evil.*/i', 'pcre')).toEqual([]);
+ });
+
+ it('skips unsupported object paths (file:name)', () => {
+ const result = parseStixPattern("[file:name = 'payload.exe']");
+ expect(result).toEqual([]);
+ });
+
+ it('skips unsupported object paths (process:pid)', () => {
+ const result = parseStixPattern("[process:pid = '1234']");
+ expect(result).toEqual([]);
+ });
+
+ it('skips unsupported object paths (windows-registry-key:key)', () => {
+ const result = parseStixPattern("[windows-registry-key:key = 'HKLM\\\\Software\\\\Evil']");
+ expect(result).toEqual([]);
+ });
+
+ it('skips LIKE comparisons (not a literal IOC)', () => {
+ // LIKE uses a different syntax — no `= '...'` form, so it's simply not matched
+ const result = parseStixPattern("[domain-name:value LIKE '%evil%']");
+ expect(result).toEqual([]);
+ });
+
+ it('skips MATCHES comparisons', () => {
+ const result = parseStixPattern("[domain-name:value MATCHES '^evil.*\\.com$']");
+ expect(result).toEqual([]);
+ });
+
+ it('skips != comparisons', () => {
+ const result = parseStixPattern("[ipv4-addr:value != '1.2.3.4']");
+ expect(result).toEqual([]);
+ });
+
+ it('returns [] for empty string', () => {
+ expect(parseStixPattern('')).toEqual([]);
+ });
+
+ it('returns [] for malformed/unclosed bracket', () => {
+ expect(parseStixPattern('[ipv4-addr:value = ')).toEqual([]);
+ });
+
+ it('tolerates null cast through (returns [])', () => {
+ // Cast through — tolerant API
+ expect(parseStixPattern(null as unknown as string)).toEqual([]);
+ });
+
+ it('tolerates undefined cast through (returns [])', () => {
+ expect(parseStixPattern(undefined as unknown as string)).toEqual([]);
+ });
+});
+
+// ── Canonicalization ──────────────────────────────────────────────────────────
+
+describe('parseStixPattern — value canonicalization', () => {
+ it('lowercases ip values', () => {
+ // IPv6 hex digits
+ const [ioc] = parseStixPattern("[ipv6-addr:value = '2001:DB8::1']");
+ expect(ioc.value).toBe('2001:db8::1');
+ });
+
+ it('lowercases domain values', () => {
+ const [ioc] = parseStixPattern("[domain-name:value = 'Evil.EXAMPLE.COM']");
+ expect(ioc.value).toBe('evil.example.com');
+ });
+
+ it('lowercases email values', () => {
+ const [ioc] = parseStixPattern("[email-addr:value = 'ATTACKER@EVIL.EXAMPLE.COM']");
+ expect(ioc.value).toBe('attacker@evil.example.com');
+ });
+
+ it('lowercases hash values', () => {
+ const [ioc] = parseStixPattern("[file:hashes.MD5 = 'AABB1122CCDD3344EEFF5566778899AA']");
+ expect(ioc.value).toBe('aabb1122ccdd3344eeff5566778899aa');
+ });
+
+ it('preserves URL case (path is case-sensitive)', () => {
+ const url = 'https://evil.example.com/PAYLOAD/Stage2.exe';
+ const [ioc] = parseStixPattern(`[url:value = '${url}']`);
+ expect(ioc.value).toBe(url);
+ });
+
+ it('unescapes STIX backslash escape (\\\\)', () => {
+ const [ioc] = parseStixPattern("[url:value = 'https://evil.test/a\\\\b']");
+ // \\\\ in STIX source → \\ in JS string → unescape → single \
+ expect(ioc.value).toBe('https://evil.test/a\\b');
+ });
+
+ it("unescapes STIX single-quote escape (\\')", () => {
+ const [ioc] = parseStixPattern("[url:value = 'https://evil.test/it\\'s']");
+ expect(ioc.value).toBe("https://evil.test/it's");
+ });
+});
+
+// ── Structural integrity ──────────────────────────────────────────────────────
+
+describe('parseStixPattern — structural integrity', () => {
+ it('all records satisfy the ExtractedIoc shape', () => {
+ const result = parseStixPattern(
+ "[ipv4-addr:value = '1.2.3.4'] OR [file:hashes.MD5 = 'aabbccddeeff00112233445566778899']"
+ );
+ for (const ioc of result) {
+ expect(hasRequiredFields(ioc)).toBe(true);
+ }
+ });
+
+ it('does not set defanged field (STIX values are fanged/live)', () => {
+ const result = parseStixPattern("[ipv4-addr:value = '1.2.3.4']");
+ expect(result[0]).not.toHaveProperty('defanged');
+ });
+
+ it('does not set port field (STIX indicator patterns rarely carry socket form)', () => {
+ const result = parseStixPattern("[ipv4-addr:value = '1.2.3.4']");
+ expect(result[0]).not.toHaveProperty('port');
+ });
+});
+
+// ── Value validation and address tiering ─────────────────────────────────────
+
+describe('parseStixPattern — value validation', () => {
+ // The object path is the only thing establishing the type, so a feed can put
+ // anything on the right-hand side and it used to be taken at face value.
+ it('rejects a hash whose length does not match the declared algorithm', () => {
+ // 8 chars declared as MD5. This used to be emitted as a discriminating hash
+ // and then filed under sha256 by the promote task's length fallback.
+ expect(parseStixPattern("[file:hashes.MD5 = 'AABB1122']")).toEqual([]);
+ });
+
+ it('rejects a hash containing non-hex characters', () => {
+ expect(parseStixPattern("[file:hashes.MD5 = 'zzbb1122ccdd3344eeff5566778899aa']")).toEqual([]);
+ });
+
+ it('accepts a correctly sized sha-256', () => {
+ const [ioc] = parseStixPattern(
+ "[file:hashes.'SHA-256' = '44a2ab4206fc5d5d33974adbc3fd2a80966e7a88167914794f524fa29a3d8e8e']"
+ );
+ expect(ioc.type).toBe('hash');
+ expect(ioc.tier).toBe('discriminating');
+ });
+
+ // An ipv4-addr value that is not an address reaches an ES `ip` field, which is a
+ // permanent item-level rejection rather than merely a bad row.
+ it('rejects an ipv4-addr value that is not an address', () => {
+ expect(parseStixPattern("[ipv4-addr:value = 'not-an-ip']")).toEqual([]);
+ });
+
+ it.each([
+ ['no dot at all', 'notadomain'],
+ ['empty label', 'evil..example.com'],
+ ['numeric TLD', 'evil.example.123'],
+ ['trailing hyphen label', 'evil-.example.com'],
+ ])('rejects a domain that cannot resolve (%s)', (_label, value) => {
+ expect(parseStixPattern(`[domain-name:value = '${value}']`)).toEqual([]);
+ });
+
+ it('rejects a url with a non-http scheme', () => {
+ expect(parseStixPattern("[url:value = 'file:///etc/passwd']")).toEqual([]);
+ });
+
+ it('rejects an email that is not an address', () => {
+ expect(parseStixPattern("[email-addr:value = 'nope']")).toEqual([]);
+ });
+});
+
+describe('parseStixPattern — private and reserved addresses', () => {
+ // `contextual` is promotable, so these used to become live Indicator Match rows
+ // matching essentially all internal traffic.
+ it.each([
+ ['RFC1918', '10.0.0.1'],
+ ['loopback', '127.0.0.1'],
+ ['link-local', '169.254.169.254'],
+ ['IPv6 loopback', '::1'],
+ ['IPv6 unique-local', 'fc00::1'],
+ ])('tiers %s as reference rather than contextual', (_label, value) => {
+ const [ioc] = parseStixPattern(`[ipv4-addr:value = '${value}']`);
+ expect(ioc.tier).toBe('reference');
+ expect(ioc.tier_basis).toBe('private_ip');
+ });
+
+ it('leaves a public address contextual', () => {
+ const [ioc] = parseStixPattern("[ipv4-addr:value = '185.220.101.45']");
+ expect(ioc.tier).toBe('contextual');
+ expect(ioc.tier_basis).toBe('stix_pattern');
+ });
+});
diff --git a/x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/stix/parse_pattern.ts b/x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/stix/parse_pattern.ts
new file mode 100644
index 0000000000000..9bced3c3609b0
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/stix/parse_pattern.ts
@@ -0,0 +1,213 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import net from 'node:net';
+import type { IocType } from '../../../../common/threat_intel';
+import type { ExtractedIoc, IocTier } from '../../services/extract_iocs';
+import { isNonRoutableIPv4, isNonRoutableIPv6 } from '../../lib/ip_ranges';
+
+/**
+ * Pattern dialects that are not observable patterns — skip them entirely.
+ * STIX (or unspecified → assumed stix) is the only parseable dialect here.
+ */
+const NON_STIX_DIALECTS = new Set([
+ 'yara',
+ 'snort',
+ 'sigma',
+ 'pcre',
+ 'tanium-signal',
+ 'spl',
+ 'kql',
+ 'eql',
+]);
+
+/**
+ * Matches a single STIX `=` comparison: