Skip to content

Commit fefcec8

Browse files
committed
feat(threat-intel): RSS, STIX, and TAXII source adapters
1 parent 4714edc commit fefcec8

15 files changed

Lines changed: 2677 additions & 0 deletions

File tree

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import { createHash } from 'crypto';
9+
import { buildFingerprint } from './fingerprint';
10+
11+
describe('buildFingerprint', () => {
12+
it('length-prefixes each part so the seed is unambiguous', () => {
13+
// The seed is `<len>:<part>` per part, concatenated. There is no workflow-side
14+
// computation to match: the definitions only pass `content_fingerprint`
15+
// through, and the engine has no sha256 Liquid filter, so this encoding is
16+
// ours alone and only has to be self-consistent.
17+
const expected = createHash('sha256')
18+
.update('28:https://example.com/feed.xml7:item-425:Title')
19+
.digest('hex');
20+
expect(buildFingerprint(['https://example.com/feed.xml', 'item-42', 'Title'])).toBe(expected);
21+
});
22+
23+
// A plain join on `:` collided across part boundaries, so two different feed
24+
// items produced one fingerprint and the second was deduplicated away as
25+
// already-ingested. Titles, URLs, and ids routinely contain colons.
26+
it('does not collide when a colon moves across a part boundary', () => {
27+
expect(buildFingerprint(['a:b', 'c'])).not.toBe(buildFingerprint(['a', 'b:c']));
28+
});
29+
30+
it('does not collide when a part boundary shifts', () => {
31+
expect(buildFingerprint(['https://evil.test', 'a'])).not.toBe(
32+
buildFingerprint(['https://evil.test:a', ''])
33+
);
34+
});
35+
36+
it('returns a 64-char hex digest', () => {
37+
expect(buildFingerprint(['a', 'b'])).toMatch(/^[0-9a-f]{64}$/);
38+
});
39+
40+
it('NFKC-normalizes parts so unicode equivalents collapse', () => {
41+
// U+FB01 (fi ligature) vs ASCII "fi".
42+
expect(buildFingerprint(['url', 'id', '\ufb01nal'])).toBe(
43+
buildFingerprint(['url', 'id', 'final'])
44+
);
45+
});
46+
47+
it('trims leading/trailing whitespace per part', () => {
48+
expect(buildFingerprint([' url ', ' id '])).toBe(buildFingerprint(['url', 'id']));
49+
});
50+
51+
it('treats undefined/null parts as empty strings (still positional)', () => {
52+
// Two leading missing parts are still part of the seed shape — a
53+
// re-fetch with the same shape produces the same digest.
54+
const a = buildFingerprint([undefined, undefined, 'id']);
55+
const b = buildFingerprint([undefined, undefined, 'id']);
56+
expect(a).toBe(b);
57+
// …but a missing part is *positionally distinct* from the part
58+
// moving up by one — `:::id` and `id` produce different digests.
59+
expect(a).not.toBe(buildFingerprint(['id']));
60+
});
61+
62+
it('produces a stable digest for the same logical input', () => {
63+
const fp1 = buildFingerprint(['https://example.com', 'id-1', 'modified-2026-05-01']);
64+
const fp2 = buildFingerprint(['https://example.com', 'id-1', 'modified-2026-05-01']);
65+
expect(fp1).toBe(fp2);
66+
});
67+
68+
it('produces different digests when the version stamp changes', () => {
69+
const a = buildFingerprint(['https://example.com', 'id-1', '2026-05-01']);
70+
const b = buildFingerprint(['https://example.com', 'id-1', '2026-05-02']);
71+
expect(a).not.toBe(b);
72+
});
73+
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import { createHash } from 'crypto';
9+
10+
/**
11+
* SHA-256 over NFKC-normalized parts, each length-prefixed.
12+
*
13+
* A plain `join(':')` is ambiguous: `['a:b', 'c']` and `['a', 'b:c']` produce the
14+
* same seed, so two different items collide on one fingerprint and the second is
15+
* deduplicated away as though it had already been ingested. The parts here are
16+
* feed-controlled titles, URLs, and ids, which routinely contain colons, so this
17+
* is reachable rather than theoretical.
18+
*
19+
* Length-prefixing makes the seed unambiguous. Nothing outside this module
20+
* recomputes these values (the workflows only pass `content_fingerprint` through),
21+
* so the change is safe; already-stored reports will be re-ingested once under
22+
* their new fingerprint.
23+
*/
24+
export const buildFingerprint = (parts: ReadonlyArray<string | undefined | null>): string => {
25+
const seed = parts
26+
.map((part) => (part ?? '').trim().normalize('NFKC'))
27+
.map((part) => `${part.length}:${part}`)
28+
.join('');
29+
return createHash('sha256').update(seed).digest('hex');
30+
};
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import { parseRssFeed } from './parse_rss';
9+
10+
const RSS2 = `<?xml version="1.0" encoding="UTF-8"?>
11+
<rss version="2.0">
12+
<channel>
13+
<title>Acme Threat Research</title>
14+
<language>en-US</language>
15+
<item>
16+
<title>APT-1 campaign</title>
17+
<link>https://acme.example/posts/apt1</link>
18+
<guid isPermaLink="false">acme:apt1</guid>
19+
<pubDate>Mon, 12 May 2025 09:30:00 GMT</pubDate>
20+
<description><![CDATA[<p>Brief summary of <b>APT-1</b>.</p>]]></description>
21+
</item>
22+
<item>
23+
<title>Ransomware uptick</title>
24+
<link>https://acme.example/posts/ransom</link>
25+
<pubDate>Tue, 13 May 2025 10:00:00 GMT</pubDate>
26+
<description>Plain summary.</description>
27+
</item>
28+
<item>
29+
<!-- intentionally missing every identifier — should be dropped -->
30+
<description>Orphan item with no id, link, or guid.</description>
31+
</item>
32+
</channel>
33+
</rss>`;
34+
35+
const ATOM = `<?xml version="1.0" encoding="utf-8"?>
36+
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
37+
<title>Vendor Labs</title>
38+
<entry>
39+
<id>tag:vendor.example,2025:post-1</id>
40+
<title>Post one</title>
41+
<link rel="alternate" href="https://vendor.example/post-1"/>
42+
<updated>2025-05-12T09:30:00Z</updated>
43+
<summary>Short summary.</summary>
44+
<content type="html">&lt;p&gt;Long body&lt;/p&gt;</content>
45+
</entry>
46+
<entry>
47+
<id>tag:vendor.example,2025:post-2</id>
48+
<title>Post two</title>
49+
<link href="https://vendor.example/post-2"/>
50+
<published>2025-05-11T08:00:00Z</published>
51+
<summary>Only a summary.</summary>
52+
</entry>
53+
</feed>`;
54+
55+
const RDF = `<?xml version="1.0"?>
56+
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
57+
<channel rdf:about="https://example.com">
58+
<title>RDF Feed</title>
59+
<dc:language>en</dc:language>
60+
</channel>
61+
<item rdf:about="https://example.com/posts/1">
62+
<title>RDF Item</title>
63+
<link>https://example.com/posts/1</link>
64+
<description>RDF body.</description>
65+
<dc:date>2025-05-12T09:30:00Z</dc:date>
66+
</item>
67+
</rdf:RDF>`;
68+
69+
describe('parseRssFeed', () => {
70+
it('returns an empty result for an empty input', async () => {
71+
const parsed = await parseRssFeed('');
72+
expect(parsed).toEqual({ feedTitle: '', entries: [] });
73+
});
74+
75+
it('parses an RSS 2.0 feed and drops items without an identifier', async () => {
76+
const parsed = await parseRssFeed(RSS2);
77+
expect(parsed.feedTitle).toBe('Acme Threat Research');
78+
expect(parsed.language).toBe('en');
79+
expect(parsed.entries).toHaveLength(2);
80+
expect(parsed.entries[0]).toMatchObject({
81+
id: 'acme:apt1',
82+
title: 'APT-1 campaign',
83+
link: 'https://acme.example/posts/apt1',
84+
publishedAt: new Date('Mon, 12 May 2025 09:30:00 GMT').toISOString(),
85+
});
86+
// CDATA contents preserved as-is on bodyHtml.
87+
expect(parsed.entries[0].bodyHtml).toContain('<b>APT-1</b>');
88+
});
89+
90+
it('parses an Atom feed and prefers updated over published', async () => {
91+
const parsed = await parseRssFeed(ATOM);
92+
expect(parsed.feedTitle).toBe('Vendor Labs');
93+
expect(parsed.language).toBe('en');
94+
expect(parsed.entries).toHaveLength(2);
95+
expect(parsed.entries[0].publishedAt).toBe('2025-05-12T09:30:00.000Z');
96+
expect(parsed.entries[0].link).toBe('https://vendor.example/post-1');
97+
expect(parsed.entries[0].bodyHtml).toBe('<p>Long body</p>');
98+
expect(parsed.entries[1].publishedAt).toBe('2025-05-11T08:00:00.000Z');
99+
});
100+
101+
it('parses an RDF / RSS 1.0 feed using rdf:about as the id', async () => {
102+
const parsed = await parseRssFeed(RDF);
103+
expect(parsed.feedTitle).toBe('RDF Feed');
104+
expect(parsed.language).toBe('en');
105+
expect(parsed.entries).toHaveLength(1);
106+
expect(parsed.entries[0]).toMatchObject({
107+
id: 'https://example.com/posts/1',
108+
title: 'RDF Item',
109+
link: 'https://example.com/posts/1',
110+
publishedAt: '2025-05-12T09:30:00.000Z',
111+
});
112+
});
113+
114+
it('returns an empty result for an unrecognized root element', async () => {
115+
const parsed = await parseRssFeed('<?xml version="1.0"?><unknown/>');
116+
expect(parsed).toEqual({ feedTitle: '', entries: [] });
117+
});
118+
});
119+
120+
// RSS 2.0 permits an item with a description and no title, and real advisory feeds
121+
// publish them. Every parser branch leaves `body` empty and puts the description in
122+
// `bodyHtml`, so a `title || body` check dropped all of them.
123+
describe('parseRssFeed — description-only items', () => {
124+
it('keeps an RSS 2.0 item that has a description but no title', async () => {
125+
const feed = `<?xml version="1.0" encoding="UTF-8"?>
126+
<rss version="2.0">
127+
<channel>
128+
<title>Advisories</title>
129+
<item>
130+
<guid>adv-1</guid>
131+
<description>Threat actor deployed ransomware via 185.220.101.45.</description>
132+
</item>
133+
</channel>
134+
</rss>`;
135+
136+
const parsed = await parseRssFeed(feed);
137+
138+
expect(parsed.entries).toHaveLength(1);
139+
expect(parsed.entries[0].id).toBe('adv-1');
140+
expect(parsed.entries[0].bodyHtml).toContain('ransomware');
141+
});
142+
143+
it('still drops an item with no identifier', async () => {
144+
const feed = `<?xml version="1.0" encoding="UTF-8"?>
145+
<rss version="2.0">
146+
<channel>
147+
<title>Advisories</title>
148+
<item><description>No guid and no link.</description></item>
149+
</channel>
150+
</rss>`;
151+
152+
expect((await parseRssFeed(feed)).entries).toHaveLength(0);
153+
});
154+
});

0 commit comments

Comments
 (0)