Skip to content

Commit 156341f

Browse files
committed
fix: update search filtering to give more accurate results
1 parent d0b48f2 commit 156341f

3 files changed

Lines changed: 185 additions & 1 deletion

File tree

attack-search/__tests__/search-service.test.js

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,17 @@ describe('SearchService', () => {
2222
});
2323

2424
beforeEach(() => {
25+
global.base_url = '/';
2526
searchService = new SearchService('search-service', null);
27+
searchService.render_container = {
28+
append: jest.fn(),
29+
html: jest.fn(),
30+
};
2631
});
2732

2833
afterEach(async () => {
2934
searchService = null;
35+
delete global.base_url;
3036
});
3137

3238
it('Access data from mock-index.json', () => {
@@ -111,4 +117,93 @@ describe('SearchService', () => {
111117
});
112118

113119
});
120+
121+
test('Keeps only exact ATT&CK ID matches and references, with the object first', async () => {
122+
const documents = {
123+
1: {
124+
id: 1,
125+
title: 'TA577, Group G1037',
126+
path: '/groups/G1037/index.html',
127+
content: 'A group with no reference to the queried technique.',
128+
attackId: 'G1037',
129+
},
130+
2: {
131+
id: 2,
132+
title: 'Ingress Tool Transfer, Technique T1105 - Enterprise',
133+
path: '/techniques/T1105/index.html',
134+
content: 'The T1105 technique.',
135+
attackId: 'T1105',
136+
},
137+
3: {
138+
id: 3,
139+
title: 'A valid reference',
140+
path: '/resources/reference/index.html',
141+
content: 'This page references T1105.',
142+
},
143+
};
144+
searchService.attackIndex.search = jest.fn().mockResolvedValue([{ field: 'title', result: [1, 2, 3] }]);
145+
searchService.resolveSearchResults = jest.fn(async positions => positions.map(position => documents[position]));
146+
147+
await searchService.query('t1105');
148+
149+
expect(searchService.allSearchResults.map(result => result.id)).toEqual([2, 3]);
150+
});
151+
152+
test('Treats a four-digit query as an exact ATT&CK ID suffix search', async () => {
153+
const documents = {
154+
1: {
155+
id: 1,
156+
title: 'TA577, Group G1037',
157+
path: '/groups/G1037/index.html',
158+
content: 'A group with no reference to the queried technique.',
159+
attackId: 'G1037',
160+
},
161+
2: {
162+
id: 2,
163+
title: 'Data from Local System, Technique T1005 - Enterprise',
164+
path: '/techniques/T1005/index.html',
165+
content: 'The T1005 technique.',
166+
attackId: 'T1005',
167+
},
168+
3: {
169+
id: 3,
170+
title: 'Matching software, Software S1005',
171+
path: '/software/S1005/index.html',
172+
content: 'The S1005 software.',
173+
attackId: 'S1005',
174+
},
175+
4: {
176+
id: 4,
177+
title: 'A valid reference',
178+
path: '/resources/reference/index.html',
179+
content: 'This page references T1005.',
180+
},
181+
5: {
182+
id: 5,
183+
title: 'Data from Local System: Archive Collected Data, Sub-technique T1005.001',
184+
path: '/techniques/T1005/001/index.html',
185+
content: 'The T1005.001 sub-technique.',
186+
attackId: 'T1005.001',
187+
},
188+
};
189+
searchService.attackIndex.search = jest.fn().mockResolvedValue([{ field: 'title', result: [1, 3, 4, 2, 5] }]);
190+
searchService.resolveSearchResults = jest.fn(async positions => positions.map(position => documents[position]));
191+
192+
await searchService.query('1005');
193+
194+
expect(searchService.allSearchResults.map(result => result.id)).toEqual([2, 5, 3, 4]);
195+
});
196+
197+
test('Preserves result ordering for non-ID queries', async () => {
198+
const documents = {
199+
1: { id: 1, title: 'First result', path: '/resources/faq/index.html', content: 'Resources' },
200+
2: { id: 2, title: 'Second result', path: '/resources/attackcon/index.html', content: 'Resources' },
201+
};
202+
searchService.attackIndex.search = jest.fn().mockResolvedValue([{ field: 'title', result: [1, 2] }]);
203+
searchService.resolveSearchResults = jest.fn(async positions => positions.map(position => documents[position]));
204+
205+
await searchService.query('Resources');
206+
207+
expect(searchService.allSearchResults.map(result => result.id)).toEqual([1, 2]);
208+
});
114209
});

attack-search/src/search-service.js

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,10 +313,84 @@ module.exports = class SearchService {
313313
* ]
314314
*/
315315

316-
this.allSearchResults = await this.#setSearchResults(results);
316+
this.allSearchResults = this.#filterAndPromoteExactAttackIdMatches(await this.#setSearchResults(results));
317317
this.#renderFilteredSearchResults();
318318
}
319319

320+
/**
321+
* Limits ATT&CK ID searches to matching objects, their sub-techniques, and genuine references.
322+
* Non-ID and multi-token queries retain FlexSearch's existing ordering.
323+
*
324+
* @private
325+
* @param {Array<Object>} documents - Search results in their existing relevance order.
326+
* @returns {Array<Object>} Exact ATT&CK ID results, with the matching object detail page first when applicable.
327+
*/
328+
#filterAndPromoteExactAttackIdMatches(documents) {
329+
const query = this.currentQuery.clean;
330+
const isExactAttackId = /^[A-Z]+\d+(?:\.\d+)?$/i.test(query);
331+
const isNumericIdSuffix = /^\d{4}$/.test(query);
332+
// If user queries for normal text and not attack ids, normal search takes place
333+
if (!isExactAttackId && !isNumericIdSuffix) return documents;
334+
335+
const normalizedQuery = query.toUpperCase();
336+
337+
// Collect the IDs stored on object-detail search records. Resource and reference pages have no attackId.
338+
const candidateAttackIds = documents
339+
.map(document => document.attackId?.toUpperCase())
340+
.filter(Boolean);
341+
342+
let directAttackIds;
343+
if (isExactAttackId) {
344+
// A complete query such as T1005 refers directly to that one ID.
345+
directAttackIds = [normalizedQuery];
346+
} else {
347+
// A numeric query such as 1005 may match T1005, S1005, or another complete ATT&CK ID.
348+
const numericSuffixPattern = new RegExp(`^[A-Z]+${normalizedQuery}$`);
349+
directAttackIds = [...new Set(candidateAttackIds.filter(attackId => numericSuffixPattern.test(attackId)))];
350+
}
351+
352+
// Include sub-techniques of a matching parent technique, such as T1005.001 for a T1005 query.
353+
const subTechniqueIds = candidateAttackIds.filter((attackId) => directAttackIds.some((directAttackId) => (
354+
directAttackId.startsWith('T')
355+
&& !directAttackId.includes('.')
356+
&& attackId.startsWith(`${directAttackId}.`)
357+
)));
358+
const matchingAttackIds = [...new Set([...directAttackIds, ...subTechniqueIds])];
359+
if (matchingAttackIds.length === 0) return [];
360+
361+
// Put parent techniques first, then their sub-techniques, followed by other matching ATT&CK object types.
362+
const exactMatches = documents.filter(document => matchingAttackIds.includes(document.attackId?.toUpperCase()));
363+
exactMatches.sort((first, second) => {
364+
const firstIsTechnique = first.attackId.startsWith('T');
365+
const secondIsTechnique = second.attackId.startsWith('T');
366+
if (firstIsTechnique !== secondIsTechnique) return firstIsTechnique ? -1 : 1;
367+
368+
const firstIsSubTechnique = first.attackId.includes('.');
369+
const secondIsSubTechnique = second.attackId.includes('.');
370+
if (firstIsSubTechnique !== secondIsSubTechnique) return firstIsSubTechnique ? 1 : -1;
371+
372+
return 0;
373+
});
374+
375+
// Escape dots in sub-technique IDs before making one expression that matches only whole IDs.
376+
const escapedIds = matchingAttackIds.map(attackId => attackId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
377+
// A trailing period is valid sentence punctuation, unless it begins a sub-technique suffix such as .001.
378+
const exactIdInText = new RegExp(
379+
`(^|[^A-Z0-9.])(?:${escapedIds.join('|')})(?=$|[^A-Z0-9.]|\\.(?!\\d))`,
380+
'i',
381+
);
382+
383+
// Add pages that reference a matching ID, but do not add an object-detail page twice.
384+
const referencedDocuments = documents.filter((document) => {
385+
const title = document.title ?? '';
386+
const content = document.content ?? '';
387+
const referencesMatchingId = exactIdInText.test(title) || exactIdInText.test(content);
388+
return referencesMatchingId && !exactMatches.includes(document);
389+
});
390+
391+
return exactMatches.concat(referencedDocuments);
392+
}
393+
320394
/**
321395
* Renders the search results on the web page based on the given search result page.
322396
* If the search query is empty, it will show the "Load More Results" button.

modules/search/search.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"x-mitre-data-component": "datacomponents",
3030
"x-mitre-detection-strategy": "detectionstrategies",
3131
}
32+
searchable_object_path_prefixes = set(object_path_prefixes.values()) | {"techniques"}
3233

3334

3435
def generate_index():
@@ -64,6 +65,7 @@ def generate_index():
6465
"title": title,
6566
"path": path,
6667
"content": cleancontent,
68+
"attackId": get_search_attack_id(path),
6769
"pageType": file_type,
6870
"domains": get_domains(title, path, domain_lookup),
6971
}
@@ -136,6 +138,19 @@ def should_skip_search_path(path):
136138
return bool(re.search(r"/sidebar-[^/]+/index\.html$", path))
137139

138140

141+
def get_search_attack_id(path):
142+
"""Return the ATT&CK ID represented by a canonical object detail-page path."""
143+
subtechnique_match = re.fullmatch(r"/techniques/(T\d+)/(\d{3})/index\.html", path)
144+
if subtechnique_match:
145+
return f"{subtechnique_match.group(1)}.{subtechnique_match.group(2)}"
146+
147+
object_match = re.fullmatch(r"/([^/]+)/([A-Z]+\d+(?:\.\d+)?)/index\.html", path)
148+
if object_match and object_match.group(1) in searchable_object_path_prefixes:
149+
return object_match.group(2)
150+
151+
return None
152+
153+
139154
def get_domains(title, path=None, domain_lookup=None):
140155
"""Get ATT&CK domains for a search result, preferring source metadata before title inference."""
141156
if path and domain_lookup and path in domain_lookup:

0 commit comments

Comments
 (0)