diff --git a/.towncrier.template.md b/.towncrier.template.md deleted file mode 100644 index f0884fcd1cb..00000000000 --- a/.towncrier.template.md +++ /dev/null @@ -1,37 +0,0 @@ -{%- if top_line -%} -# v{{ versiondata.version }} ({{ versiondata.date }}) -{%- endif -%} -{%- for section, _ in sections.items() -%} - {%- if section -%}## {{ section }}{%- endif -%} - {%- if sections[section] -%} - {%- for category, val in definitions.items() if category in sections[section] %} - -## {{ definitions[category]['name'] }} - {% if definitions[category]['showcontent'] %} - {%- for text, values in sections[section][category].items() %} - {%- if values[0].endswith("/0)") %} - -* {{ definitions[category]['name'] }} without explicit PR/issue numbers - {{ text }} - {%- else %} - -* {{ text }} {{ values|join(',\n ') }} - {%- endif %} - - {%- endfor %} - {%- else %} - -* {{ sections[section][category]['']|join(', ') }} - {%- endif %} - {%- if sections[section][category]|length == 0 %} - -No significant changes. - {%- else %} - {%- endif %} - - {%- endfor %} - {%- else %} - -No significant changes. - {%- endif %} -{%- endfor %} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 1b2ad5a91a5..8eb252bd954 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -188,7 +188,7 @@ Run commands from the repo root unless a subdirectory is called out. - Pull requests should target the `develop` branch per `CONTRIBUTING.md`. - The PR template expects a reviewer and a `CHANGELOG.md` update when appropriate. -- `pyproject.toml` configures Towncrier for `CHANGELOG.md`; prefer the repository's release-note fragment workflow when one is present instead of hand-editing generated changelog sections. +- The website version is configured in `modules/site_config.py`; keep it aligned with release tags and docs. - Do not assume `master` is the integration branch just because GitHub Pages deploys from it. - Use Conventional Commit style git messages. diff --git a/CHANGELOG.md b/CHANGELOG.md index e421173575a..702dccac9ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Website Changelog +## v4.4.3 (2026-05-12) + +### Features + +* Release ATT&CK content version 19.1. + See detailed changes [here](https://github.com/mitre/cti/releases/tag/ATT%26CK-v19.1). + ## v4.4.2 (2026-04-28) ### Features diff --git a/attack-search/__tests__/attack-index.test.js b/attack-search/__tests__/attack-index.test.js index 0ec2cc44692..02c06562c74 100644 --- a/attack-search/__tests__/attack-index.test.js +++ b/attack-search/__tests__/attack-index.test.js @@ -84,8 +84,8 @@ describe('AttackIndex', () => { // Search the title index for "The" const results = await attackIndex.search('The', ['title']); - // Index 2 through 6 (inclusive) should have "The" in the title - const expectedResult = [{"field":"title","result":[2,3,4,5,6]}] + // Index 2 through 10 (inclusive) should have "The" in the title + const expectedResult = [{"field":"title","result":[2,3,4,5,6,7,8,9,10]}] expect(results).toEqual(expectedResult); }); diff --git a/attack-search/__tests__/search-events.test.js b/attack-search/__tests__/search-events.test.js new file mode 100644 index 00000000000..f5dc8a6a20d --- /dev/null +++ b/attack-search/__tests__/search-events.test.js @@ -0,0 +1,119 @@ +const mockJqueryCalls = []; +const mockJqueryApis = {}; + +const mockJquery = jest.fn((selector) => { + if (typeof selector === 'object' && selector.dataset) { + return { + data: jest.fn((key) => selector.dataset?.[toCamelCase(key)]), + }; + } + + const api = { + addClass: jest.fn(() => api), + attr: jest.fn(() => api), + data: jest.fn(), + focus: jest.fn(() => api), + hide: jest.fn(() => api), + on: jest.fn((events, delegatedSelector, handler) => { + mockJqueryCalls.push({ + delegatedSelector: typeof delegatedSelector === 'string' ? delegatedSelector : null, + events, + handler: typeof delegatedSelector === 'function' ? delegatedSelector : handler, + selector, + }); + return api; + }), + prop: jest.fn(() => api), + removeClass: jest.fn(() => api), + show: jest.fn(() => api), + toggle: jest.fn(() => api), + toggleClass: jest.fn(() => api), + val: jest.fn(() => ''), + keyup: jest.fn((handler) => { + mockJqueryCalls.push({ + delegatedSelector: null, + events: 'keyup', + handler, + selector, + }); + return api; + }), + }; + mockJqueryApis[selector] = api; + return api; +}); + +mockJquery.getJSON = jest.fn(); + +jest.mock('jquery', () => mockJquery); + +console.debug = jest.fn(); +console.error = jest.fn(); + +describe('search event bindings', () => { + beforeEach(() => { + jest.resetModules(); + mockJqueryCalls.length = 0; + Object.keys(mockJqueryApis).forEach(key => delete mockJqueryApis[key]); + global.build_uuid = 'test-build'; + global.document = {}; + global.localStorage = { + getItem: jest.fn(), + removeItem: jest.fn(), + setItem: jest.fn(), + }; + global.window = {}; + }); + + afterEach(() => { + delete global.build_uuid; + delete global.document; + delete global.localStorage; + delete global.window; + }); + + test('filter controls listen for touch activation events', () => { + require('../src/index'); + + expect(eventsForSelector('[data-search-filter-dropdown-toggle]')).toContain('touchend'); + expect(eventsForSelector('[data-search-filter-page-type]')).toContain('touchend'); + expect(eventsForSelector('[data-search-filter-domain]')).toContain('touchend'); + expect(eventsForSelector('[data-search-filter-domain-action]')).toContain('touchend'); + expect(eventsForSelector('[data-search-filter-group-action]')).toContain('touchend'); + expect(eventsForSelector('[data-search-filter-reset]')).toContain('touchend'); + }); + + test('dropdown toggles open before the search service is initialized', () => { + require('../src/index'); + + const handler = handlerForSelector('[data-search-filter-dropdown-toggle]'); + handler({ + currentTarget: { dataset: { searchFilterDropdownToggle: 'core' } }, + stopPropagation: jest.fn(), + type: 'click', + }); + + expect(mockJqueryApis['[data-search-filter-dropdown-toggle="core"]'].toggleClass) + .toHaveBeenCalledWith('open', true); + expect(mockJqueryApis['[data-search-filter-dropdown-toggle="core"]'].attr) + .toHaveBeenCalledWith('aria-expanded', 'true'); + expect(mockJqueryApis['[data-search-filter-dropdown="core"]'].toggle) + .toHaveBeenCalledWith(true); + expect(mockJqueryApis['[data-search-filter-dropdown="core"]'].attr) + .toHaveBeenCalledWith('aria-hidden', 'false'); + }); +}); + +function eventsForSelector(selector) { + return mockJqueryCalls + .filter(call => call.selector === selector || call.delegatedSelector === selector) + .flatMap(call => call.events.split(/\s+/)); +} + +function handlerForSelector(selector) { + return mockJqueryCalls.find(call => call.selector === selector || call.delegatedSelector === selector).handler; +} + +function toCamelCase(key) { + return key.replace(/-([a-z])/g, (_, char) => char.toUpperCase()); +} diff --git a/attack-search/__tests__/search-filters.test.js b/attack-search/__tests__/search-filters.test.js index 8acd59f0474..a50581379b4 100644 --- a/attack-search/__tests__/search-filters.test.js +++ b/attack-search/__tests__/search-filters.test.js @@ -49,6 +49,20 @@ describe('SearchService filters', () => { pageType: 'resources', domains: [], }, + { + id: 6, + title: 'Uncategorized', + content: 'Credential uncategorized material', + pageType: 'misc', + domains: [], + }, + { + id: 7, + title: 'Multi-domain analytic', + content: 'Credential analytic', + pageType: 'analytics', + domains: ['enterprise', 'mobile'], + }, ]; }); @@ -61,6 +75,16 @@ describe('SearchService filters', () => { expect(searchService.applyFilters(documents)).toEqual(documents); }); + test('unknown page types are searchable by default but excluded by narrowed page type filters', () => { + searchService.setSelectedPageTypes(['techniques']); + + expect(searchService.applyFilters(documents).map(result => result.id)).toEqual([1]); + + searchService.selectAllPageTypes(); + + expect(searchService.applyFilters(documents).map(result => result.id)).toEqual([1, 2, 3, 4, 5, 6, 7]); + }); + test('page type filters treat techniques and sub-techniques independently', () => { searchService.setSelectedPageTypes(['techniques']); @@ -70,7 +94,7 @@ describe('SearchService filters', () => { test('domain filters only match documents with explicit selected domains', () => { searchService.setSelectedDomains(['enterprise']); - expect(searchService.applyFilters(documents).map(result => result.id)).toEqual([1, 2]); + expect(searchService.applyFilters(documents).map(result => result.id)).toEqual([1, 2, 7]); }); test('empty page type selection returns no results', () => { @@ -86,7 +110,7 @@ describe('SearchService filters', () => { const counts = searchService.getFilterCounts(documents); expect(counts.pageTypes).toEqual({ - analytics: 0, + analytics: 1, assets: 0, campaigns: 0, datacomponents: 0, @@ -102,11 +126,22 @@ describe('SearchService filters', () => { }); expect(counts.domains).toEqual({ enterprise: 2, - ics: 0, mobile: 1, + ics: 0, }); }); + test('multi-domain results count once per matching domain but once in total results', () => { + const counts = searchService.getFilterCounts(documents); + + expect(counts.domains).toEqual({ + enterprise: 3, + mobile: 2, + ics: 0, + }); + expect(searchService.applyFilters(documents)).toHaveLength(7); + }); + test('clear session resets filters and query-scoped results', () => { searchService.allSearchResults = documents; searchService.setSelectedPageTypes(['techniques']); @@ -138,15 +173,35 @@ describe('SearchService filters', () => { searchService.closeFilterDropdowns(); expect(searchService.getOpenFilterDropdown()).toBeNull(); - expect(searchService.applyFilters(documents).map(result => result.id)).toEqual([1, 2]); + expect(searchService.applyFilters(documents).map(result => result.id)).toEqual([1, 2, 7]); }); - test('full filters panel closes subgroup dropdowns', () => { - searchService.toggleFilterDropdown('cti'); + test('pagination exposes first last and five nearby pages for larger result sets', () => { + const manyDocuments = Array.from({ length: 183 }, (_, index) => ({ + id: index + 1, + title: `Result ${index + 1}`, + content: 'Credential result', + pageType: 'resources', + domains: [], + })); + + searchService.allSearchResults = manyDocuments; + searchService.resetFilters(); + + expect(searchService.getPaginationState()).toEqual({ + currentPage: 1, + endResult: 10, + pageSize: 10, + pages: [1, 2, 3, 4, 5, 'ellipsis', 19], + showPagination: true, + startResult: 1, + totalPages: 19, + totalResults: 183, + }); - searchService.toggleFiltersPanel(); + searchService.goToPage(12); - expect(searchService.getOpenFilterDropdown()).toBeNull(); - expect(searchService.filtersExpanded).toBe(true); + expect(searchService.getPaginationState().pages).toEqual([1, 'ellipsis', 10, 11, 12, 13, 14, 'ellipsis', 19]); + expect(searchService.getPaginationState().startResult).toBe(111); }); }); diff --git a/attack-search/__tests__/search-loader.test.js b/attack-search/__tests__/search-loader.test.js new file mode 100644 index 00000000000..e2b90ce395b --- /dev/null +++ b/attack-search/__tests__/search-loader.test.js @@ -0,0 +1,26 @@ +const { loadSearchDocuments } = require('../src/search-loader'); + +describe('search loader', () => { + test('skips missing optional search files and returns loaded documents', async () => { + const getJSON = jest.fn((url) => { + if (url.endsWith('misc.json')) return Promise.reject({ status: 404 }); + return Promise.resolve([{ id: url, title: 'Loaded' }]); + }); + + const documents = await loadSearchDocuments('/search/', ['techniques.json', 'misc.json'], getJSON); + + expect(documents).toEqual([{ id: '/search/techniques.json', title: 'Loaded' }]); + }); + + test('fails when every optional search file is missing', async () => { + const getJSON = jest.fn(() => Promise.reject({ status: 404 })); + + await expect(loadSearchDocuments('/search/', ['misc.json'], getJSON)).rejects.toThrow('No search index files'); + }); + + test('fails malformed existing search files', async () => { + const getJSON = jest.fn(() => Promise.reject(new SyntaxError('Unexpected token'))); + + await expect(loadSearchDocuments('/search/', ['techniques.json'], getJSON)).rejects.toThrow('Unexpected token'); + }); +}); diff --git a/attack-search/__tests__/search-style.test.js b/attack-search/__tests__/search-style.test.js new file mode 100644 index 00000000000..73a43d2718e --- /dev/null +++ b/attack-search/__tests__/search-style.test.js @@ -0,0 +1,50 @@ +const fs = require('fs'); +const path = require('path'); + +describe('search styles', () => { + test('marks page type and domain result badges with distinct classes', () => { + const searchService = fs.readFileSync( + path.join(__dirname, '../src/search-service.js'), + 'utf8', + ); + + expect(searchService).toContain('search-result-badge-page-type'); + expect(searchService).toContain('search-result-badge-domain'); + }); + + test('renders result badges as subtle prominent chips', () => { + const styles = fs.readFileSync( + path.join(__dirname, '../../attack-style/components/_search.scss'), + 'utf8', + ); + + const badgeStyle = styles.match(/\.search-result-badge\s*\{(?[^}]+)\}/)?.groups?.body ?? ''; + + expect(badgeStyle).toContain('color: white;'); + expect(badgeStyle).toContain('font-size: 0.8rem;'); + + expect(styles).toContain('.search-result-badge-page-type'); + expect(styles).toContain('border-color: color-functions.color(active);'); + expect(styles).toContain('background: color-functions.color-alternate(active, 1.5);'); + expect(styles).toContain('.search-result-badge-domain'); + expect(styles).toContain('border-color: color-functions.color(deemphasis);'); + expect(styles).toContain('background: color-functions.color(deemphasis);'); + }); + + test('renders search pagination controls as accessible icon buttons', () => { + const searchService = fs.readFileSync( + path.join(__dirname, '../src/search-service.js'), + 'utf8', + ); + const styles = fs.readFileSync( + path.join(__dirname, '../../attack-style/components/_search.scss'), + 'utf8', + ); + + expect(searchService).toContain('aria-label="Previous page"'); + expect(searchService).toContain('aria-label="Next page"'); + expect(searchService).not.toContain('>Prev'); + expect(searchService).not.toContain('>Next'); + expect(styles).toContain('.search-pagination-icon'); + }); +}); diff --git a/attack-search/__tests__/search-template.test.js b/attack-search/__tests__/search-template.test.js new file mode 100644 index 00000000000..68d2c3f7b0a --- /dev/null +++ b/attack-search/__tests__/search-template.test.js @@ -0,0 +1,36 @@ +const fs = require('fs'); +const path = require('path'); + +describe('search template', () => { + test('places result counts and pagination above the result list', () => { + const template = fs.readFileSync( + path.join(__dirname, '../../attack-theme/templates/macros/search.html'), + 'utf8', + ); + + const searchBodyStart = template.indexOf('
'); + const footerStart = template.indexOf('