Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
55660a5
fix: standardize STIX file path handling
jondricek Aug 7, 2026
509e547
fix(search): settle bulkPut when an IndexedDB write fails
ppcvote Aug 23, 2026
7742c5c
docs(changelog): note the bulkPut settle fix
ppcvote Aug 23, 2026
05ee4b2
docs: update pull request template
jondricek Aug 26, 2026
d0b48f2
fix: fix loading sidebar on https redirects
jondricek Sep 2, 2026
062f477
fix(search): surface a failed index build instead of spinning forever
ppcvote Sep 4, 2026
0d58ced
test(search): cover the warm restore path as well as the cold start
ppcvote Sep 4, 2026
156341f
fix: update search filtering to give more accurate results
adpare Sep 10, 2026
9412110
feat: add dark mode
jondricek Sep 14, 2026
e16e200
Merge remote-tracking branch 'origin/develop' into search-fixes
jondricek Sep 14, 2026
0c1c87d
fix: update search cache schema version to 4
jondricek Sep 14, 2026
d95d666
fix: update search service tests and adjust attack ID path prefixes
jondricek Sep 14, 2026
d0196b5
Merge pull request #645 from mitre-attack/search-fixes
jondricek Sep 14, 2026
ffd0522
fix(search): implement cache invalidation for failed search index builds
jondricek Sep 14, 2026
8fc68d0
Merge pull request #637 from ppcvote/fix/bulkput-settle
jondricek Sep 14, 2026
91264f6
feat: implement theme toggle synchronization and update navigation la…
jondricek Sep 15, 2026
4b239e2
Merge branch 'develop' of https://github.com/mitre-attack/attack-webs…
jondricek Sep 15, 2026
8858f23
fix(changelog): update bug fixes section with improved search results…
jondricek Sep 15, 2026
fee027d
feat(theme): enhance theme toggle functionality and update matrix nav…
jondricek Sep 15, 2026
a3a7ec4
fix(theme): handle toggle clicks before DOM ready
jondricek Sep 15, 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
22 changes: 5 additions & 17 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,7 @@
<!-- PULL REQUEST TEMPLATE -->
<!-- 1. fill in the below sections to the best of your ability -->
<!-- 2. Assign and/or mention a reviewer (typically @isaisabel) -->
<!-- 3. Pull requests should target the develop branch -->
<!-- 4. Make sure to update CHANGELOG.md to reflect what has changed. -->
## Description

## Description of what has changed
<!-- Add a short description of what changed -->
<!-- -->
<!-- For example, "changed a thing on /page to be better" -->
## Problem Solved

## Issues addressed by pull request
<!-- If relevant, add list of issues addressed by the pull request -->
<!-- If no issues are relevant, omit this section -->
<!-- Prefix issue list with keywords such as "closes", -->
<!-- "resolves", or "fixes" to automatically close the -->
<!-- issue s when the request is merged. -->
<!-- -->
<!-- For example, "Closes #24. See also #25, #26" -->
## Alternatives Considered

## Related issue(s)
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Website Changelog

## Unreleased

### Features

* Add dark mode!

### Bug Fixes

* Improve search results for exact ATT&CK ID and numeric ID queries by prioritizing matching object pages and relevant references.
* Fix sidebar loading for HTTPS redirects.
* Settle the search index write when an IndexedDB write fails, instead of leaving the promise pending and the search spinner up.
* Disable the search controls and explain why when the search index cannot be built, instead of leaving the spinner running for as long as the page is open.

## v5.0.0 (2026-08-06)

* Release ATT&CK content version 19.2.
Expand Down
25 changes: 25 additions & 0 deletions attack-search/__tests__/indexed-db-wrapper.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,29 @@ describe('IndexedDBWrapper', () => {
const count = await contentDb.count();
expect(count).toEqual(data.length);
});

// A failed write must settle the promise. Racing against a sentinel tells a
// rejection apart from a promise that never settles at all, which a plain
// rejects assertion cannot do: it would time out and look like a slow test.
const settle = (promise) => Promise.race([
promise.then(() => 'resolved', (error) => `rejected:${error.message}`),
new Promise((resolve) => setTimeout(() => resolve('HUNG'), 1000)),
]);

test('Bulk put rejects when the underlying write fails', async () => {
jest.spyOn(contentDb.indexeddb[contentDb.tableName], 'bulkPut')
.mockRejectedValue(new Error('QuotaExceededError'));

await expect(settle(contentDb.bulkPut(data))).resolves.toBe('rejected:QuotaExceededError');
});

test('Bulk put rejects when a later chunk fails', async () => {
let calls = 0;
jest.spyOn(contentDb.indexeddb[contentDb.tableName], 'bulkPut')
.mockImplementation(() => (++calls === 2
? Promise.reject(new Error('DatabaseClosedError'))
: Promise.resolve()));

await expect(settle(contentDb.bulkPut(data, 1))).resolves.toBe('rejected:DatabaseClosedError');
});
});
82 changes: 82 additions & 0 deletions attack-search/__tests__/search-events.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,90 @@ describe('search event bindings', () => {
expect(mockJqueryApis['[data-search-filter-dropdown="core"]'].attr)
.toHaveBeenCalledWith('aria-hidden', 'false');
});

test('a failed index build stops search from waiting for an index that never arrives', async () => {
await loadIndexWithAFailingColdStart();

const parsingIcon = mockJqueryApis['#search-parsing-icon'];
parsingIcon.show.mockClear();
parsingIcon.hide.mockClear();

handlerForSelector('#search-input')({ target: { value: 'mimikatz' } });

// Before the fix `search` looped on the loaded flag, so it showed the parsing icon on
// its first pass and kept doing so every 100ms for as long as the page stayed open.
expect(parsingIcon.show).not.toHaveBeenCalled();
expect(parsingIcon.hide).toHaveBeenCalled();
});

test('a failed restore from the cache is not reported as a successful load', async () => {
const { cacheKey, deleteCachedDatabase } = await loadIndexWithAFailingWarmRestore();

// The catch used to set the loaded flag false and the finally set it straight back to
// true, so `search` went on to query an index that was never populated.
expect(mockJqueryApis['#search-input'].prop).toHaveBeenCalledWith('disabled', true);
expect(mockJqueryApis['#search-icon'].addClass).toHaveBeenCalledWith('error-icon');
expect(global.localStorage.removeItem).toHaveBeenCalledWith(cacheKey);
expect(deleteCachedDatabase).toHaveBeenCalledTimes(1);
});

test('a failed index build puts the search controls into their unavailable state', async () => {
await loadIndexWithAFailingColdStart();

expect(mockJqueryApis['#search-input'].prop).toHaveBeenCalledWith('disabled', true);
expect(mockJqueryApis['#search-button'].prop).toHaveBeenCalledWith('disabled', true);
expect(mockJqueryApis['#search-icon'].removeClass).toHaveBeenCalledWith('search-icon');
expect(mockJqueryApis['#search-icon'].addClass).toHaveBeenCalledWith('error-icon');
expect(mockJqueryApis['#search-button'].prop)
.toHaveBeenCalledWith('title', expect.stringContaining('search index could not be built'));
});
});

// Load the module on the cold-start path with the document fetch failing, and run the
// debouncer straight through so the input handler reaches `search` without a timer.
async function loadIndexWithAFailingColdStart() {
global.window = { indexedDB: {} };
global.localStorage.getItem.mockReturnValue(null);

jest.doMock('../src/search-loader.js', () => ({
loadSearchDocuments: () => Promise.reject(new Error('documents unavailable')),
}));
jest.doMock('../src/debouncer.js', () => class {
debounce(callback) {
callback();
}
});

require('../src/index');
await new Promise(resolve => setImmediate(resolve));
}

// Load the module on the cached path, with restoring the index from IndexedDB failing.
async function loadIndexWithAFailingWarmRestore() {
const { searchCacheCompatibilityVersion, searchCacheSchemaVersion } = require('../src/settings');
const version = `${searchCacheSchemaVersion}-${searchCacheCompatibilityVersion}`;
const cacheKey = `saved_uuid_search_schema_${version}`;
const deleteCachedDatabase = jest.fn(() => Promise.resolve());

global.window = { indexedDB: {} };
global.localStorage.getItem.mockReturnValue(`${global.build_uuid}-search-${version}`);

jest.doMock('../src/search-service.js', () => class {
constructor() {
this.db = { indexeddb: { delete: deleteCachedDatabase } };
}

initializeAsync() {
return Promise.reject(new Error('cached index is unreadable'));
}
});

require('../src/index');
await new Promise(resolve => setImmediate(resolve));

return { cacheKey, deleteCachedDatabase };
}

function eventsForSelector(selector) {
return mockJqueryCalls
.filter(call => call.selector === selector || call.delegatedSelector === selector)
Expand Down
119 changes: 119 additions & 0 deletions attack-search/__tests__/search-service.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,17 @@ describe('SearchService', () => {
});

beforeEach(() => {
global.base_url = '/';
searchService = new SearchService('search-service', null);
searchService.render_container = {
append: jest.fn(),
html: jest.fn(),
};
});

afterEach(async () => {
searchService = null;
delete global.base_url;
});

it('Access data from mock-index.json', () => {
Expand Down Expand Up @@ -111,4 +117,117 @@ describe('SearchService', () => {
});

});

test('Keeps only exact ATT&CK ID matches and references, with the object first', async () => {
const documents = {
1: {
id: 1,
title: 'TA577, Group G1037',
path: '/groups/G1037/index.html',
content: 'A group with no reference to the queried technique.',
attackId: 'G1037',
},
2: {
id: 2,
title: 'Ingress Tool Transfer, Technique T1105 - Enterprise',
path: '/techniques/T1105/index.html',
content: 'The T1105 technique.',
attackId: 'T1105',
},
3: {
id: 3,
title: 'A valid reference',
path: '/resources/reference/index.html',
content: 'This page references T1105.',
},
};
searchService.attackIndex.search = jest.fn().mockResolvedValue([{ field: 'title', result: [1, 2, 3] }]);
searchService.resolveSearchResults = jest.fn(async positions => positions.map(position => documents[position]));

await searchService.query('t1105');

expect(searchService.allSearchResults.map(result => result.id)).toEqual([2, 3]);
});

test('Treats a four-digit query as an exact ATT&CK ID suffix search', async () => {
const documents = {
1: {
id: 1,
title: 'TA577, Group G1037',
path: '/groups/G1037/index.html',
content: 'A group with no reference to the queried technique.',
attackId: 'G1037',
},
2: {
id: 2,
title: 'Data from Local System, Technique T1005 - Enterprise',
path: '/techniques/T1005/index.html',
content: 'The T1005 technique.',
attackId: 'T1005',
},
3: {
id: 3,
title: 'Matching software, Software S1005',
path: '/software/S1005/index.html',
content: 'The S1005 software.',
attackId: 'S1005',
},
4: {
id: 4,
title: 'A valid reference',
path: '/resources/reference/index.html',
content: 'This page references T1005.',
},
5: {
id: 5,
title: 'Data from Local System: Archive Collected Data, Sub-technique T1005.001',
path: '/techniques/T1005/001/index.html',
content: 'The T1005.001 sub-technique.',
attackId: 'T1005.001',
},
};
searchService.attackIndex.search = jest.fn().mockResolvedValue([{ field: 'title', result: [1, 3, 4, 2, 5] }]);
searchService.resolveSearchResults = jest.fn(async positions => positions.map(position => documents[position]));

await searchService.query('1005');

expect(searchService.allSearchResults.map(result => result.id)).toEqual([2, 5, 3, 4]);
});

test.each(['TA0001', '0001'])('Promotes a tactic page for ATT&CK ID query %s', async (query) => {
const documents = {
1: {
id: 1,
title: 'A valid reference',
path: '/resources/reference/index.html',
content: 'This page references TA0001.',
},
2: {
id: 2,
title: 'Initial Access, Tactic TA0001 - Enterprise',
path: '/tactics/TA0001/index.html',
content: 'The TA0001 tactic.',
attackId: 'TA0001',
},
};
searchService.attackIndex.search = jest.fn().mockResolvedValue([{ field: 'title', result: [1, 2] }]);
searchService.resolveSearchResults = jest.fn(async positions => positions.map(position => documents[position]));

await searchService.query(query);

expect(searchService.allSearchResults.map(result => result.id)).toEqual([2, 1]);
});

test('Preserves result ordering for non-ID queries', async () => {
const documents = {
1: { id: 1, title: 'First result', path: '/resources/faq/index.html', content: 'Resources' },
2: { id: 2, title: 'Second result', path: '/resources/attackcon/index.html', content: 'Resources' },
};
searchService.attackIndex.search = jest.fn().mockResolvedValue([{ field: 'title', result: [1, 2] }]);
searchService.resolveSearchResults = jest.fn(async positions => positions.map(position => documents[position]));

await searchService.query('Resources');

expect(searchService.allSearchResults.map(result => result.id)).toEqual([1, 2]);
});
});
2 changes: 1 addition & 1 deletion attack-search/__tests__/search-style.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ describe('search styles', () => {

const badgeStyle = styles.match(/\.search-result-badge\s*\{(?<body>[^}]+)\}/)?.groups?.body ?? '';

expect(badgeStyle).toContain('color: white;');
expect(badgeStyle).toContain('color: color-functions.on-color(active);');
expect(badgeStyle).toContain('font-size: 0.8rem;');

expect(styles).toContain('.search-result-badge-page-type');
Expand Down
Loading
Loading