|
| 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"><p>Long body</p></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