Skip to content
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
3c14ba6
feat(threat-intel): RSS, STIX, and TAXII source adapters
stephmilovic Aug 26, 2026
d5891e7
fix(threat-intel): redact feed URLs in the remaining adapter log lines
stephmilovic Aug 26, 2026
6b62139
fix(threat-intel): redact the last raw feed URL in the taxii adapter
stephmilovic Aug 26, 2026
5e10320
docs(threat-intel): correct why the scoped client cannot reach the pl…
stephmilovic Aug 27, 2026
0137dfb
feat(threat-intel): resolve Atom/RSS namespace and type semantics in …
stephmilovic Aug 27, 2026
57b1b4c
refactor(threat-intel): trim comment verbosity in parse_rss and rss_a…
stephmilovic Aug 27, 2026
7eea22f
docs(threat-intel): note why the indicator index sits outside `.kibana-`
stephmilovic Aug 27, 2026
158436e
refactor(threat-intel): convert RSS markup to bounded text, drop HTML…
jonwalstedt Aug 28, 2026
8325c7d
Narrow ingest adapters to structured RSS
stephmilovic Sep 1, 2026
5dd6ba0
Narrow the fetch adapter source contract
stephmilovic Sep 1, 2026
c2af0e7
Exclude hidden RSS fragment content
stephmilovic Sep 1, 2026
c309e6c
fix(threat-intel): resolve RSS adapter URLs from the code catalog
stephmilovic Sep 2, 2026
eabdc63
feat(threat-intel): remaining adapters, the dispatcher, and the fetch…
stephmilovic Aug 26, 2026
f3f69c0
fix(threat-intel): redact feed URLs and validate every KEV field the …
stephmilovic Aug 26, 2026
2e65093
refactor(threat-intel): keep REST adapters catalog-only, drop content…
jonwalstedt Aug 28, 2026
53c2ab4
Remove unreachable threat intel adapters
stephmilovic Sep 1, 2026
f27260f
Remove dead text-list reference metadata
stephmilovic Sep 1, 2026
996c632
Remove dead KEV reference metadata
stephmilovic Sep 1, 2026
ad9d322
Satisfy text-list chunking lint
stephmilovic Sep 1, 2026
7208883
Remove dead text-list reference classification
stephmilovic Sep 1, 2026
3f61825
Address scope-review findings from deep PR review
stephmilovic Sep 1, 2026
3edf6ea
fix(threat-intel): resolve KEV and text-list adapter URLs from the co…
stephmilovic Sep 2, 2026
5813f23
Deslop: drop decorative section banners and a no-op comment
stephmilovic Sep 4, 2026
86cf8c1
Fix prettier formatting in the indicator-list adapter tests
stephmilovic Sep 4, 2026
0995b58
fix(threat-intel): report a KEV shape failure as a shape failure
stephmilovic Sep 4, 2026
0b556ab
Merge branch 'main' into threat-intel-8-adapters-rest
elasticmachine Sep 8, 2026
97c0101
Address review: KEV optional-field type guard, source name, tierBasis…
stephmilovic Sep 8, 2026
5627e13
Changes from node scripts/check
kibanamachine Sep 8, 2026
b0cf683
Merge remote-tracking branch 'upstream/main' into threat-intel-8-adap…
stephmilovic Sep 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/*
* 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.
*/

module.exports = {
preset: '@kbn/test',
rootDir: '../../../../../../..',
roots: ['<rootDir>/x-pack/solutions/security/plugins/security_solution/public/threat_intel'],
coverageDirectory:
'<rootDir>/target/kibana-coverage/jest/x-pack/solutions/security/plugins/security_solution/public/threat_intel',
coverageReporters: ['text', 'html'],
collectCoverageFrom: [
'<rootDir>/x-pack/solutions/security/plugins/security_solution/public/threat_intel/**/*.{ts,tsx}',
],
moduleNameMapper: require('../../server/__mocks__/module_name_map'),
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* 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 React from 'react';
import type { PublicStepDefinition } from '@kbn/workflows-extensions/public';
import { fetchSourceStepCommonDefinition } from '../../../../../common/threat_intel/workflows/step_types/fetch_source/fetch_source_common';

/** YAML editor schema for threat_intel.fetch_source (handler is server-side). */
export const fetchSourceStepDefinition: PublicStepDefinition = {
...fetchSourceStepCommonDefinition,
icon: React.lazy(() =>
import('@elastic/eui/es/components/icon/assets/download')
Comment thread
jonwalstedt marked this conversation as resolved.
Outdated
.then(({ icon }) => ({ default: icon }))
.catch(() =>
import('@elastic/eui/es/components/icon/assets/globe').then(({ icon }) => ({
default: icon,
}))
)
),
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/*
* 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.
*/

export { fetchSourceStepDefinition } from './fetch_source_step';
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/*
* 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.
*/

export { registerThreatIntelWorkflowSteps } from './register_workflow_steps';
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* 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 type { PublicStepDefinition } from '@kbn/workflows-extensions/public';
import { workflowsExtensionsMock } from '@kbn/workflows-extensions/public/mocks';
import { registerThreatIntelWorkflowSteps } from './register_workflow_steps';
import { fetchSourceStepDefinition } from './fetch_source';
import { FETCH_SOURCE_STEP_TYPE } from '../../../../common/threat_intel/workflows/step_types/fetch_source/fetch_source_common';

type StepLoader = () => Promise<PublicStepDefinition | undefined>;

describe('registerThreatIntelWorkflowSteps (public)', () => {
it('registers exactly one step definition for threat_intel.fetch_source', () => {
const workflowsExtensions = workflowsExtensionsMock.createSetup();

registerThreatIntelWorkflowSteps(workflowsExtensions);

expect(workflowsExtensions.registerStepDefinition).toHaveBeenCalledTimes(1);
});

it('async loader resolves to the fetch_source step definition', async () => {
const workflowsExtensions = workflowsExtensionsMock.createSetup();

registerThreatIntelWorkflowSteps(workflowsExtensions);

const [loader] = workflowsExtensions.registerStepDefinition.mock.calls.map(
([arg]) => arg as StepLoader
);

const definition = await loader();
expect(definition).toBe(fetchSourceStepDefinition);
// Sanity-check the id matches the YAML-side step type so the editor
// can find the schema for `type: threat_intel.fetch_source`.
expect(definition?.id).toBe(FETCH_SOURCE_STEP_TYPE);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* 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 type { WorkflowsExtensionsPublicPluginSetup } from '@kbn/workflows-extensions/public';

/**
* Register threat-intel-owned workflow step types with the public
* `workflowsExtensions` setup contract so the YAML editor's strict-schema
* validator (see
* `workflows_management/public/features/validate_workflow_yaml/model/use_workflow_json_schema.ts`)
* picks them up.
*
* Mirrors the server-side `registerThreatIntelWorkflowSteps` in
* `server/threat_intel/workflows/step_types/index.ts`. Steps are
* loaded lazily via async loaders to keep the heavy
* `fetch_source_common` Zod schemas off the critical-path bundle until the
* editor actually requests them.
*
* The caller is expected to invoke this only when the optional
* `workflowsExtensions` plugin is present and the
* `threatIntelSupplyEnabled` experimental feature is on, matching
* the gating policy applied server-side. Without that gate, dark-flagged
* deployments would advertise a step type whose handler is never
* registered.
*/
export const registerThreatIntelWorkflowSteps = (
workflowsExtensions: WorkflowsExtensionsPublicPluginSetup
): void => {
workflowsExtensions.registerStepDefinition(async () =>
import('./fetch_source').then((m) => m.fetchSourceStepDefinition)
);
};
Original file line number Diff line number Diff line change
@@ -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 `<len>:<part>` 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);
});
});
Original file line number Diff line number Diff line change
@@ -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 | undefined | null>): string => {
const seed = parts
.map((part) => (part ?? '').trim().normalize('NFKC'))
.map((part) => `${part.length}:${part}`)
.join('');
return createHash('sha256').update(seed).digest('hex');
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/*
* 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.
*/

export { runAdapter, UnknownAdapterError } from './run_adapter';
export type { AdapterRunContext, FetchAdapter, NormalizedReport, SourceHit } from './types';
Loading
Loading