Skip to content

Commit 3ad1b0b

Browse files
fix(threat-intel): resolve KEV and text-list adapter URLs from the code catalog
After elastic#287201, feed URLs live in catalog_source_urls rather than source config. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 17ae16a commit 3ad1b0b

5 files changed

Lines changed: 41 additions & 48 deletions

File tree

x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/kev/kev_adapter.test.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -68,12 +68,11 @@ const makeContext = (body: string, status = 200): AdapterRunContext => {
6868
};
6969
};
7070

71-
const makeSource = (url = FEED_URL): SourceHit => ({
71+
const makeSource = (): SourceHit => ({
7272
_id: 'kev:cisa-known-exploited-vulnerabilities',
7373
_source: {
7474
adapter_type: 'kev',
7575
name: 'CISA Known Exploited Vulnerabilities',
76-
config: { url },
7776
space_id: '*',
7877
},
7978
});
@@ -206,13 +205,10 @@ describe('kevAdapter', () => {
206205
expect(headers?.['User-Agent']).toMatch(/Mozilla/);
207206
});
208207

209-
it('removes credentials from stored feed provenance', async () => {
210-
const [report] = await kevAdapter.run(
211-
makeSource(`https://feed-user:feed-password@www.cisa.gov/kev.json`),
212-
makeContext(makeEnvelope([VULN_1]))
213-
);
208+
it('stores a credential-free catalog feed URL in provenance', async () => {
209+
const [report] = await kevAdapter.run(makeSource(), makeContext(makeEnvelope([VULN_1])));
214210

215-
expect(report.source.url).toBe('https://www.cisa.gov/kev.json');
211+
expect(report.source.url).toBe(FEED_URL);
216212
expect(JSON.stringify(report)).not.toContain('feed-password');
217213
});
218214

x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/kev/kev_adapter.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { buildFingerprint } from '../fingerprint';
1010
import { severityScore } from '../../services/severity';
1111
import { buildReportContent } from '../../services/report_content';
1212
import { normalizeProvenanceUrl } from '../../services/provenance_url';
13+
import { resolveCatalogSourceUrl } from '../../../../common/threat_intel';
1314
import type { FetchAdapter, NormalizedReport, SourceHit, AdapterRunContext } from '../types';
1415

1516
/**
@@ -104,7 +105,7 @@ const parseKevVulnerability = (value: unknown): KevVulnerability | undefined =>
104105
};
105106

106107
const readFeedUrl = (source: SourceHit): string => {
107-
const url = source._source.config.url;
108+
const url = resolveCatalogSourceUrl(source._id);
108109
return typeof url === 'string' && url.length > 0 ? url : KEV_FEED_URL;
109110
};
110111

x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/run_adapter.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ describe('runAdapter', () => {
2323
_source: {
2424
adapter_type: 'manual',
2525
name: 'Analyst paste',
26-
config: {},
2726
},
2827
} as unknown as SourceHit;
2928

x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/text_indicator_list/text_indicator_list_adapter.test.ts

Lines changed: 32 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,16 @@ const okResponse = () =>
4545
headers: { 'Content-Type': 'text/plain' },
4646
});
4747

48-
const makeSource = (url: string, id = 'src-1', name = 'maltrail'): SourceHit => ({
48+
const SOURCE_ID = 'text_indicator_list:maltrail-cobaltstrike';
49+
50+
const makeSource = (
51+
id = SOURCE_ID,
52+
name = 'Maltrail — CobaltStrike C2 indicators'
53+
): SourceHit => ({
4954
_id: id,
5055
_source: {
5156
adapter_type: 'text_indicator_list',
5257
name,
53-
config: { url },
5458
enabled: true,
5559
},
5660
});
@@ -102,7 +106,7 @@ describe('textIndicatorListAdapter', () => {
102106
const fetchMock = jest.fn().mockResolvedValue(okResponse());
103107

104108
const reports = await textIndicatorListAdapter.run(
105-
makeSource(TRAIL_URL),
109+
makeSource(),
106110
makeContext(fetchMock)
107111
);
108112

@@ -115,7 +119,7 @@ describe('textIndicatorListAdapter', () => {
115119
const fetchMock = jest.fn().mockResolvedValue(okResponse());
116120

117121
const [report] = await textIndicatorListAdapter.run(
118-
makeSource(TRAIL_URL),
122+
makeSource(),
119123
makeContext(fetchMock)
120124
);
121125

@@ -129,7 +133,7 @@ describe('textIndicatorListAdapter', () => {
129133
const fetchMock = jest.fn().mockResolvedValue(okResponse());
130134

131135
const [report] = await textIndicatorListAdapter.run(
132-
makeSource(TRAIL_URL),
136+
makeSource(),
133137
makeContext(fetchMock)
134138
);
135139

@@ -143,7 +147,7 @@ describe('textIndicatorListAdapter', () => {
143147
const fetchMock = jest.fn().mockResolvedValue(okResponse());
144148

145149
const [report] = await textIndicatorListAdapter.run(
146-
makeSource(TRAIL_URL),
150+
makeSource(),
147151
makeContext(fetchMock)
148152
);
149153

@@ -177,7 +181,7 @@ describe('textIndicatorListAdapter', () => {
177181
]);
178182

179183
const reports = await textIndicatorListAdapter.run(
180-
makeSource(TRAIL_URL),
184+
makeSource(),
181185
makeContext(jest.fn().mockResolvedValue(okResponse()))
182186
);
183187
const iocs = reports.flatMap((report) => report.extracted?.iocs ?? []);
@@ -193,7 +197,7 @@ describe('textIndicatorListAdapter', () => {
193197
const fetchMock = jest.fn().mockResolvedValue(okResponse());
194198

195199
const [report] = await textIndicatorListAdapter.run(
196-
makeSource(TRAIL_URL),
200+
makeSource(),
197201
makeContext(fetchMock)
198202
);
199203

@@ -207,7 +211,7 @@ describe('textIndicatorListAdapter', () => {
207211
const fetchMock = jest.fn().mockResolvedValue(okResponse());
208212

209213
const reports = await textIndicatorListAdapter.run(
210-
makeSource('https://example.com/trail/empty.txt'),
214+
makeSource(),
211215
makeContext(fetchMock)
212216
);
213217

@@ -226,7 +230,7 @@ describe('textIndicatorListAdapter', () => {
226230
const fetchMock = jest.fn().mockResolvedValue(okResponse());
227231

228232
const reports = await textIndicatorListAdapter.run(
229-
makeSource('https://example.com/trail/noiocs.txt'),
233+
makeSource(),
230234
makeContext(fetchMock)
231235
);
232236

@@ -240,16 +244,16 @@ describe('textIndicatorListAdapter', () => {
240244

241245
await expect(
242246
textIndicatorListAdapter.run(
243-
makeSource('https://example.com/trail/missing.txt'),
247+
makeSource(),
244248
makeContext(fetchMock)
245249
)
246250
).rejects.toThrow(/HTTP 404/);
247251
});
248252

249-
it('returns [] when config.url is missing', async () => {
253+
it('returns [] when the source id has no catalog URL', async () => {
250254
const source: SourceHit = {
251-
_id: 'src-no-url',
252-
_source: { adapter_type: 'text_indicator_list', name: 'maltrail', config: {} },
255+
_id: 'text_indicator_list:unknown',
256+
_source: { adapter_type: 'text_indicator_list', name: 'maltrail' },
253257
};
254258
const fetchMock = jest.fn();
255259

@@ -291,7 +295,7 @@ describe('textIndicatorListAdapter', () => {
291295
const fetchMock = jest.fn().mockResolvedValue(okResponse());
292296

293297
const reports = await textIndicatorListAdapter.run(
294-
makeSource('https://example.com/trail/big.txt'),
298+
makeSource(),
295299
makeContext(fetchMock)
296300
);
297301

@@ -323,7 +327,7 @@ describe('textIndicatorListAdapter', () => {
323327
const fetchMock = jest.fn().mockResolvedValue(okResponse());
324328

325329
const reports = await textIndicatorListAdapter.run(
326-
makeSource('https://example.com/trail/big.txt'),
330+
makeSource(),
327331
makeContext(fetchMock)
328332
);
329333

@@ -348,7 +352,7 @@ describe('textIndicatorListAdapter', () => {
348352
const fetchMock = jest.fn().mockResolvedValue(okResponse());
349353

350354
const reports = await textIndicatorListAdapter.run(
351-
makeSource('https://example.com/trail/huge.txt'),
355+
makeSource(),
352356
makeContext(fetchMock)
353357
);
354358

@@ -372,7 +376,7 @@ describe('textIndicatorListAdapter', () => {
372376
const fetchMock = jest.fn().mockResolvedValue(okResponse());
373377

374378
const reports = await textIndicatorListAdapter.run(
375-
makeSource('https://example.com/trail/mixed2.txt'),
379+
makeSource(),
376380
makeContext(fetchMock)
377381
);
378382

@@ -411,7 +415,7 @@ describe('textIndicatorListAdapter', () => {
411415
const fetchMock = jest.fn().mockResolvedValue(okResponse());
412416

413417
const reports = await textIndicatorListAdapter.run(
414-
makeSource('https://example.com/trail/dedup.txt'),
418+
makeSource(),
415419
makeContext(fetchMock)
416420
);
417421

@@ -432,7 +436,7 @@ describe('textIndicatorListAdapter', () => {
432436
},
433437
]);
434438
const reports = await textIndicatorListAdapter.run(
435-
makeSource('https://example.com/trail/signal.txt'),
439+
makeSource(),
436440
makeContext(jest.fn().mockResolvedValue(okResponse()))
437441
);
438442
return reports.map((r) => r.content_fingerprint);
@@ -474,38 +478,31 @@ describe('textIndicatorListAdapter — attribution and credentials', () => {
474478
// attributed every custom feed's reports, and every indicator promoted from
475479
// them, to maltrail.
476480
it('attributes reports to the configured source name', async () => {
477-
const reports = await runWith(
478-
makeSource('https://feeds.example/trails/custom.txt', 'src-9', 'Acme C2 list')
479-
);
481+
const reports = await runWith(makeSource(SOURCE_ID, 'Acme C2 list'));
480482

481483
expect(reports.length).toBeGreaterThan(0);
482484
expect(reports[0].source.name).toBe('Acme C2 list');
483485
});
484486

485487
// The credential reached the stored source.url, which the promote task copies
486488
// onto the indicator document, so this leaked well past the logs.
487-
it('stores a credential-free source URL', async () => {
488-
const reports = await runWith(
489-
makeSource('https://feeduser:s3cret@feeds.example/trails/custom.txt')
490-
);
489+
it('stores a credential-free catalog source URL', async () => {
490+
const reports = await runWith(makeSource());
491491

492-
expect(reports[0].source.url).toBe('https://feeds.example/trails/custom.txt');
492+
expect(reports[0].source.url).toBe(TRAIL_URL);
493493
expect(JSON.stringify(reports[0])).not.toContain('s3cret');
494494
});
495495

496-
it('keeps the credential out of the fetch failure message', async () => {
496+
it('keeps fetch failures readable without catalog secrets', async () => {
497497
parseIndicatorListMock.mockReturnValue(BLOCKS_FIXTURE);
498498
const failing = makeContext(
499499
jest
500500
.fn()
501501
.mockResolvedValue(new Response('nope', { status: 503, statusText: 'Service Unavailable' }))
502502
);
503503

504-
await expect(
505-
textIndicatorListAdapter.run(
506-
makeSource('https://feeduser:s3cret@feeds.example/trails/custom.txt'),
507-
failing
508-
)
509-
).rejects.toThrow(/^(?!.*s3cret).*text_indicator_list fetch/s);
504+
await expect(textIndicatorListAdapter.run(makeSource(), failing)).rejects.toThrow(
505+
/HTTP 503/
506+
);
510507
});
511508
});

x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/text_indicator_list/text_indicator_list_adapter.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* 2.0.
66
*/
77

8-
import { GLOBAL_SPACE_ID } from '../../../../common/threat_intel';
8+
import { GLOBAL_SPACE_ID, resolveCatalogSourceUrl } from '../../../../common/threat_intel';
99
import { fetchUrlForContext, redactUrl } from '../http_client';
1010
import { buildFingerprint } from '../fingerprint';
1111
import { DEFAULT_SEVERITY_LEVEL, DEFAULT_SEVERITY_SCORE } from '../../services/severity';
@@ -21,7 +21,7 @@ const SOURCE_DOC_REF_INDEX = 'maltrail:trail';
2121
const MAX_NESTED_PER_DOC = 5000;
2222

2323
const readTrailUrl = (source: SourceHit): string | undefined => {
24-
const url = source._source.config.url;
24+
const url = resolveCatalogSourceUrl(source._id);
2525
return typeof url === 'string' && url.length > 0 ? url : undefined;
2626
};
2727

@@ -94,7 +94,7 @@ export const textIndicatorListAdapter: FetchAdapter = {
9494
const log = context.logger.get('text-indicator-list-adapter');
9595
const url = readTrailUrl(source);
9696
if (!url) {
97-
log.warn(`Source ${source._id} has no config.url — skipping`);
97+
log.warn(`Source ${source._id} has no catalog URL — skipping`);
9898
return [];
9999
}
100100

0 commit comments

Comments
 (0)