diff --git a/.env.template b/.env.template index 474165c107d..5da2da8c730 100644 --- a/.env.template +++ b/.env.template @@ -1,3 +1,8 @@ +PELICAN_AUTHOR="MITRE" +PELICAN_SITENAME="ATT&CK" +PELICAN_SITEURL="https://attack.mitre.org" +PELICAN_TIMEZONE="America/New_York" +PELICAN_DEFAULT_LANG="en" ATTACK_VERSION_ARCHIVES="attack-version-archives" BANNER_ENABLED=True BANNER_MESSAGE="This is a custom instance of the MITRE ATT&CK Website. The official website can be found at attack.mitre.org." @@ -5,3 +10,8 @@ STIX_LOCATION_ENTERPRISE="https://raw.githubusercontent.com/mitre/cti/master/ent STIX_LOCATION_MOBILE="https://raw.githubusercontent.com/mitre/cti/master/mobile-attack/mobile-attack.json" STIX_LOCATION_ICS="https://raw.githubusercontent.com/mitre/cti/master/ics-attack/ics-attack.json" STIX_LOCATION_PRE="https://raw.githubusercontent.com/mitre/cti/master/pre-attack/pre-attack.json" +WORKBENCH_USER="" +WORKBENCH_API_KEY="" +GOOGLE_ANALYTICS="" +GOOGLE_SITE_VERIFICATION="" +INCLUDE_OSANO="" diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index 7c9abfdf390..35750c5c087 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -48,6 +48,7 @@ jobs: GOOGLE_ANALYTICS: ${{ secrets.GOOGLE_ANALYTICS }} GOOGLE_SITE_VERIFICATION: ${{ secrets.GOOGLE_SITE_VERIFICATION }} INCLUDE_OSANO: true + PELICAN_SITEURL: https://attack.mitre.org - name: Cleanup build run: rm -rf attack-version-archives diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000000..1b2ad5a91a5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,199 @@ +# AGENTS.md + +This file is guidance for coding agents working in `attack-website`. + +## Scope + +- Applies to the repository root. +- There was no existing `AGENTS.md` to preserve. +- No Cursor rules were found in `.cursor/rules/` or `.cursorrules`. +- No Copilot instructions were found in `.github/copilot-instructions.md`. +- Primary human docs are `DEVELOPMENT.md`, `README.md`, `test/README.md`, and `CONTRIBUTING.md`. + +## Repo Shape + +- Root Python build system generates the static site via `update-attack.py`. +- `attack-search/` is a separate Node/CommonJS project for the search bundle. +- Search source and tests live in `attack-search/src/` and `attack-search/__tests__/`. +- `attack-style/` is a separate Node/Sass project for CSS output. +- SCSS entrypoints are `attack-style/style-attack.scss` and `attack-style/style-user.scss`. +- `attack-theme/` contains Jinja templates, static assets, and legacy browser JS. +- Theme templates and static assets live in `attack-theme/templates/` and `attack-theme/static/`. +- `modules/` contains Python modules that generate ATT&CK site content. +- `test/` provides an Nginx Docker environment for validating the built site. + +## Environment Expectations + +- Python 3 is required for the main build. +- When managing a local Python environment, prefer `uv` with a virtual environment at `.venv` in the git repository root. +- Node.js and npm are required for `attack-search/` and `attack-style/`. +- Docker is the preferred way to validate the final static output in an Nginx-like environment. +- CI currently uses Python `3.13` and Node `18.x` in `.github/workflows/gh-pages.yml`. +- Prefer CI versions when reproducing CI behavior; Docker and development docs may reference older base images. +- Production-like builds may depend on environment variables from `.github/workflows/gh-pages.yml`, including `GOOGLE_ANALYTICS`, `GOOGLE_SITE_VERIFICATION`, `INCLUDE_OSANO`, and `PELICAN_SITEURL`. + +## High-Value Commands + +Run commands from the repo root unless a subdirectory is called out. + +### Install + +- Preferred Python env: `uv venv .venv` +- Python deps: `uv pip install -r requirements.txt` +- Search deps: `cd attack-search && npm ci` +- Style deps: `cd attack-style && npm ci` +- Prefer `npm ci` over `npm install`; do not update lockfiles unless dependency changes are part of the task. + +### Build + +- Main website build: `uv run python update-attack.py --attack-brand --extras --no-test-exitstatus` +- Search bundle: `cd attack-search && npm run build` +- Search dev bundle: `cd attack-search && npm run build:dev` +- Copy built search bundle into site output: `cd attack-search && npm run copy` +- Style build: `cd attack-style && npm run build` +- Style build + copy into theme static assets: `cd attack-style && npm run build-copy` + +### Local Validation + +- Full local site validation follows `DEVELOPMENT.md` and `test/README.md`. +- Build site output first, then serve `output/` through the Docker test image. +- Test container build: `cd test && docker build -t attack-website-test .` +- Test container run: `cd test && docker run -p 80:80 -v $(pwd)/../output:/workspace attack-website-test` +- Helper script: `cd test && ./run_test.sh` + +### Lint And Format + +- Python lint (configured, not wired into CI): `ruff check .` +- Python lint autofix: `ruff check --fix .` +- Python format: `ruff format .` +- Search lint: `cd attack-search && npm run lint` +- Search lint autofix: `cd attack-search && npm run lint:fix` +- Style lint: `cd attack-style && npm run lint` + +### Tests + +- Search tests: `cd attack-search && npm test` +- Single Jest test file: `cd attack-search && npm test -- __tests__/search-service.test.js` +- Alternate single Jest file: `cd attack-search && npx jest __tests__/search-service.test.js` +- Main Python-driven site tests run through the build script, not `pytest`. +- Run specific site test categories: `uv run python update-attack.py -m tests -t size` +- Multiple site test categories: `uv run python update-attack.py -m tests -t links external_links citations` + +### Important Command Notes + +- There is no root `package.json`, `Makefile`, or single universal test runner. +- CI clearly builds the site and search bundle, but does not currently enforce Jest, ESLint, Stylelint, Ruff, or type checks. +- For Python-side testing, the narrowest supported scope is a named category (`size`, `links`, `external_links`, `citations`), not an individual test file. +- Preferred production-like validation is Nginx via Docker, not Pelican's built-in dev server. +- Pelican's built-in development server does not match production Nginx routing behavior. + +## Source Of Truth + +- Follow existing file-local conventions before applying generic preferences. +- Treat `pyproject.toml`, `attack-search/.eslintrc`, and `attack-style/.stylelintrc.json` as authoritative style configs. +- Treat `DEVELOPMENT.md` and `.github/workflows/gh-pages.yml` as authoritative for build workflow. +- In templates, respect comments that mark generated files or source-of-truth files. +- Example: `attack-theme/templates/general/base-template.html` explicitly says to edit `base-template.html`, not generated `base.html`. + +## Generated And Volatile Outputs + +- Do not edit `output/` as source; regenerate it through the build pipeline. +- Avoid direct edits to `attack-search/dist/` and `attack-style/dist/` unless the task explicitly targets generated artifacts. +- Avoid direct edits to copied assets in `attack-theme/static/` when a source file in `attack-style/` or another generator owns the output. +- Preserve generated-file comments and edit the named source template or source asset instead. + +## Python Style + +- Use 4-space indentation. +- Match Ruff formatting and the configured 120-character line length. +- Keep imports grouped as standard library, third-party, then local imports. +- Use Ruff when reorganizing imports. +- Prefer `snake_case` for variables, functions, and module names. +- Reserve `UPPER_CASE` for constants or config-like values. +- Keep modules function-oriented and simple; the codebase uses few classes outside JS. +- Type hints are not a dominant convention here; do not introduce a large typing layer unless the touched area already uses it. +- Python type hints are desired to be added over time. +- Python docstrings should follow NumPy conventions. + +## Python Error Handling + +- Raise explicit argument validation errors when input is invalid. +- Preserve existing CLI behavior in `update-attack.py`; it is the operational entry point. +- Avoid silent failures in build code unless the surrounding module already degrades intentionally. +- When editing build logic, prefer predictable failures with useful messages over hidden fallbacks. + +## JavaScript Style + +- In `attack-search/`, use CommonJS (`require`, `module.exports`) unless the file already uses something else. +- Keep local imports consistent with surrounding code; many files include `.js` extensions intentionally for webpack resolution. +- Prefer single quotes in `attack-search/` unless the local file already differs. +- Semicolons are the norm in `attack-search/`; keep them. +- Use `camelCase` for variables and functions, `PascalCase` for classes. +- Modern JS features are acceptable in `attack-search/` (async/await, private methods, optional chaining, nullish coalescing). +- Favor small, explicit DOM interactions over framework-style abstractions; this repo is not React-based. +- In legacy `attack-theme/static/scripts/`, preserve the file's existing style rather than forcing `attack-search/` conventions into older code. +- Preserve browser compatibility assumptions in legacy theme scripts; avoid modernizing syntax there unless the build path transpiles it. + +## JavaScript Error Handling And Testing + +- For async startup flows, follow the existing `try/catch` and `.catch()` patterns. +- Log actionable errors with `console.error` or `console.debug` where the surrounding code already does so. +- Prefer graceful UI degradation for browser capability issues instead of crashing the page. +- Jest tests live in `attack-search/__tests__/` and usually use `describe`, `test` or `it`, mocked globals, and fixture files. +- When adding tests, follow the existing jsdom/jQuery mocking style rather than introducing a new browser test stack. + +## Templates, HTML, And Content Generation + +- Jinja templates typically place `set` statements and imports at the top. +- Preserve existing macro usage patterns instead of inlining repeated HTML. +- Do not edit generated artifacts if the template comments point to a source template. +- Keep ATT&CK branding, banner, and version placeholders intact unless the task is explicitly about site configuration. +- Be careful with path handling and generated `index.html` semantics; many helpers normalize trailing slashes. + +## SCSS And Styling + +- `attack-style/` uses Sass modules with `@use`, not legacy `@import`. +- Preserve the layered structure: `abstracts/`, `base/`, `layout/`, `components/`. +- Import order matters because variables/functions are dependencies for later files. +- Use lowercase, hyphenated naming for classes and partial filenames. +- Reuse existing Sass variables, maps, and helper functions instead of hardcoding colors or spacing. +- Lint with Stylelint when editing SCSS. + +## Naming And File Hygiene + +- Keep filenames and identifiers consistent with the surrounding subsystem. +- Prefer focused changes over opportunistic refactors. +- Avoid renaming public paths, generated content paths, or ATT&CK URL structures unless required. +- Preserve comments that explain generation behavior, browser compatibility, or build caveats. + +## Do Not + +- Do not treat generated output as canonical source. +- Do not change ATT&CK URL structures, permalink behavior, or `index.html` generation casually. +- Do not convert `attack-search/` from CommonJS to ESM unless the task explicitly requires it. +- Do not add frontend frameworks for small browser interactions. +- Do not assume Pelican's development server is sufficient for routing-sensitive validation. + +## Agent Workflow Expectations + +- Before editing, identify which subsystem you are in: root Python build, `attack-search/`, `attack-style/`, `attack-theme/`, or `modules/`. +- Run the narrowest relevant validation for the files you touched. +- If you changed `attack-search/`, run relevant Jest tests and/or `cd attack-search && npm run lint`. +- If you changed SCSS, run `cd attack-style && npm run lint` and usually `cd attack-style && npm run build`. +- If you changed root build or content-generation code, run at least the relevant `update-attack.py` build or targeted test category. +- If your change affects rendered output or routing, validate with the Docker Nginx test environment when practical. +- For template, routing, or generated-output behavior changes, run the site build and prefer Docker/Nginx validation when practical. + +## Git And Contribution Notes + +- 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. +- Do not assume `master` is the integration branch just because GitHub Pages deploys from it. +- Use Conventional Commit style git messages. + +## When Unsure + +- Read the nearest config file and a nearby edited file before making stylistic changes. +- Prefer matching existing conventions over introducing new tools or patterns. +- Keep builds reproducible, paths stable, and generated output compatible with the current pipeline. diff --git a/CHANGELOG.md b/CHANGELOG.md index c1a15a12982..e421173575a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Website Changelog +## v4.4.2 (2026-04-28) + +### Features + +* Add filters for website search +* Release ATT&CK content version 19.0. + See detailed changes [here](https://github.com/mitre/cti/releases/tag/ATT%26CK-v19.0). + ## v4.4.1 (2025-11-13) ### Features diff --git a/LICENSE.txt b/LICENSE.txt index c126b71a5b5..a5d25006363 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2025 The MITRE Corporation + Copyright 2026 The MITRE Corporation Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/NOTICE.txt b/NOTICE.txt index 167ea9be128..59e5156d35c 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -1,4 +1,4 @@ -Copyright 2015-2025 The MITRE Corporation +Copyright 2015-2026 The MITRE Corporation Approved for Public Release; Distribution Unlimited. Case Number 19-3504. diff --git a/README.md b/README.md index 47320fbba45..f5398f3cee2 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ STIX is designed to improve many different capabilities, such as collaborative t ## Notice -Copyright 2015-2025 The MITRE Corporation +Copyright 2015-2026 The MITRE Corporation Approved for Public Release; Distribution Unlimited. Case Number 19-3504. diff --git a/attack-search/__tests__/attack-index.test.js b/attack-search/__tests__/attack-index.test.js index 16bd894c81f..0ec2cc44692 100644 --- a/attack-search/__tests__/attack-index.test.js +++ b/attack-search/__tests__/attack-index.test.js @@ -64,8 +64,22 @@ describe('AttackIndex', () => { expect(results).toEqual(expectedResult); }); + test('Searches two-character content terms', async () => { + const data = { + id: 1, + title: 'Spearphishing Link', + content: 'By using a QR code, the URL may not be exposed', + }; + + await attackIndex.add(data); + + const results = await attackIndex.search('qr', ['content'], 5, 0); + + expect(results).toEqual([{field: 'content', result: [1]}]); + }); + test('Bulk add documents to FlexSearch', async () => { - attackIndex.addBulk(data); + await attackIndex.addBulk(data); // Search the title index for "The" const results = await attackIndex.search('The', ['title']); @@ -77,7 +91,7 @@ describe('AttackIndex', () => { }); test('Paginate FlexSearch responses', async () => { - attackIndex.addBulk(data); + await attackIndex.addBulk(data); /** * limit, offset --> [ paginatedSearchResults ] @@ -121,9 +135,9 @@ describe('AttackIndex', () => { test('Resolve search results', async () => { // Index the data - attackIndex.addBulk(data); + await attackIndex.addBulk(data); - const results = await attackIndex.search('of', ['title','content'], 10, 0); + const results = await attackIndex.search('learning', ['title','content'], 10, 0); /** * results: [ diff --git a/attack-search/__tests__/mock-index.json b/attack-search/__tests__/mock-index.json index cb78505c935..f1e813bf703 100644 --- a/attack-search/__tests__/mock-index.json +++ b/attack-search/__tests__/mock-index.json @@ -2,51 +2,81 @@ { "id": 1, "title": "Introduction to Machine Learning", + "path": "/resources/machine-learning/index.html", + "pageType": "resources", + "domains": [], "content": "Machine learning is a subfield of artificial intelligence that allows machines to learn from data and improve over time without being explicitly programmed." }, { "id": 2, "title": "The Benefits of Regular Exercise", + "path": "/resources/exercise/index.html", + "pageType": "resources", + "domains": [], "content": "Regular exercise has been shown to have numerous physical and mental health benefits, including reducing the risk of chronic diseases, improving mood, and boosting energy levels." }, { "id": 3, "title": "The History of the Internet", + "path": "/resources/internet-history/index.html", + "pageType": "resources", + "domains": [], "content": "The internet was created in the 1960s as a way for researchers to share information. Over the years, it has grown to become a global network connecting billions of people and devices around the world." }, { "id": 4, "title": "The Importance of Sleep", + "path": "/resources/sleep/index.html", + "pageType": "resources", + "domains": [], "content": "Sleep is essential for maintaining physical and mental health. It helps to restore the body and mind, regulate mood and appetite, and improve memory and cognitive function." }, { "id": 5, "title": "The Art of Photography", + "path": "/resources/photography/index.html", + "pageType": "resources", + "domains": [], "content": "Photography is the art and science of capturing light and creating images that communicate a message or convey a feeling. It requires technical skill, creativity, and an eye for detail." }, { "id": 6, "title": "The Basics of Investing", + "path": "/resources/investing/index.html", + "pageType": "resources", + "domains": [], "content": "Investing is the process of allocating resources, usually money, with the expectation of generating a profit or achieving a specific goal. It involves assessing risk, researching opportunities, and making informed decisions." }, { "id": 7, "title": "The Science of Climate Change", + "path": "/resources/climate-change/index.html", + "pageType": "resources", + "domains": [], "content": "Climate change is a global phenomenon caused by increasing levels of greenhouse gases in the atmosphere. It is a complex and multifaceted issue that requires action on multiple fronts to mitigate its impact." }, { "id": 8, "title": "The Benefits of Meditation", + "path": "/resources/meditation/index.html", + "pageType": "resources", + "domains": [], "content": "Meditation is a practice that involves training the mind to focus and calm the body. It has been shown to reduce stress, improve mental clarity and concentration, and promote feelings of peace and well-being." }, { "id": 9, "title": "The Rise of Social Media", + "path": "/resources/social-media/index.html", + "pageType": "resources", + "domains": [], "content": "Social media platforms like Facebook, Twitter, and Instagram have revolutionized the way people communicate and share information. They have also raised concerns about privacy, online harassment, and the spread of misinformation." }, { "id": 10, "title": "The Benefits of a Healthy Diet", + "path": "/resources/diet/index.html", + "pageType": "resources", + "domains": [], "content": "Eating a healthy, balanced diet is essential for maintaining optimal physical and mental health. It can help to prevent chronic diseases, boost energy levels, and improve mood and cognitive function." } ] diff --git a/attack-search/__tests__/search-filters.test.js b/attack-search/__tests__/search-filters.test.js new file mode 100644 index 00000000000..8acd59f0474 --- /dev/null +++ b/attack-search/__tests__/search-filters.test.js @@ -0,0 +1,152 @@ +const SearchService = require('../src/search-service'); +import 'fake-indexeddb/auto'; + +jest.mock('jquery'); + +console.log = jest.fn(); +console.debug = jest.fn(); + +describe('SearchService filters', () => { + let searchService; + let documents; + + beforeEach(() => { + global.base_url = '/'; + searchService = new SearchService('search-service', null); + documents = [ + { + id: 1, + title: 'Credential Access', + content: 'Credential access technique', + pageType: 'techniques', + domains: ['enterprise'], + }, + { + id: 2, + title: 'Credential Access: Credentials from Password Stores', + content: 'Credential access sub-technique', + pageType: 'sub-techniques', + domains: ['enterprise'], + }, + { + id: 3, + title: 'Account Use Policies', + content: 'Credential mitigation', + pageType: 'mitigations', + domains: ['mobile'], + }, + { + id: 4, + title: 'APT29', + content: 'Credential campaign reporting', + pageType: 'groups', + domains: [], + }, + { + id: 5, + title: 'Training', + content: 'Credential reference material', + pageType: 'resources', + domains: [], + }, + ]; + }); + + afterEach(async () => { + await searchService.db.indexeddb.delete(); + searchService = null; + }); + + test('default filters include every document', () => { + expect(searchService.applyFilters(documents)).toEqual(documents); + }); + + test('page type filters treat techniques and sub-techniques independently', () => { + searchService.setSelectedPageTypes(['techniques']); + + expect(searchService.applyFilters(documents).map(result => result.id)).toEqual([1]); + }); + + test('domain filters only match documents with explicit selected domains', () => { + searchService.setSelectedDomains(['enterprise']); + + expect(searchService.applyFilters(documents).map(result => result.id)).toEqual([1, 2]); + }); + + test('empty page type selection returns no results', () => { + searchService.setSelectedPageTypes([]); + + expect(searchService.applyFilters(documents)).toEqual([]); + }); + + test('counts reflect current query results before applying the changed facet', () => { + searchService.setSelectedPageTypes(['techniques', 'sub-techniques', 'mitigations']); + searchService.setSelectedDomains(['enterprise']); + + const counts = searchService.getFilterCounts(documents); + + expect(counts.pageTypes).toEqual({ + analytics: 0, + assets: 0, + campaigns: 0, + datacomponents: 0, + detectionstrategies: 0, + groups: 0, + matrices: 0, + mitigations: 0, + resources: 0, + software: 0, + 'sub-techniques': 1, + tactics: 0, + techniques: 1, + }); + expect(counts.domains).toEqual({ + enterprise: 2, + ics: 0, + mobile: 1, + }); + }); + + test('clear session resets filters and query-scoped results', () => { + searchService.allSearchResults = documents; + searchService.setSelectedPageTypes(['techniques']); + searchService.setSelectedDomains(['enterprise']); + searchService.toggleFilterDropdown('core'); + + searchService.clearSession(); + + expect(searchService.applyFilters(documents)).toEqual(documents); + expect(searchService.allSearchResults).toEqual([]); + expect(searchService.searchResults).toEqual([]); + expect(searchService.getOpenFilterDropdown()).toBeNull(); + }); + + test('only one subgroup dropdown can be open at a time', () => { + searchService.toggleFilterDropdown('core'); + + expect(searchService.getOpenFilterDropdown()).toBe('core'); + + searchService.toggleFilterDropdown('defenses'); + + expect(searchService.getOpenFilterDropdown()).toBe('defenses'); + }); + + test('clicking away closes subgroup dropdowns without changing selections', () => { + searchService.toggleFilterDropdown('domains'); + searchService.setSelectedDomains(['enterprise']); + + searchService.closeFilterDropdowns(); + + expect(searchService.getOpenFilterDropdown()).toBeNull(); + expect(searchService.applyFilters(documents).map(result => result.id)).toEqual([1, 2]); + }); + + test('full filters panel closes subgroup dropdowns', () => { + searchService.toggleFilterDropdown('cti'); + + searchService.toggleFiltersPanel(); + + expect(searchService.getOpenFilterDropdown()).toBeNull(); + expect(searchService.filtersExpanded).toBe(true); + }); +}); diff --git a/attack-search/__tests__/search-service.test.js b/attack-search/__tests__/search-service.test.js index 3087923fd3a..32e2a3b3a19 100644 --- a/attack-search/__tests__/search-service.test.js +++ b/attack-search/__tests__/search-service.test.js @@ -1,7 +1,5 @@ const SearchService = require("../src/search-service"); -const { JSDOM } = require('jsdom'); -const { document } = new JSDOM('').window; -global.document = document; +import 'fake-indexeddb/auto'; // The following line is necessary to mock the jQuery library and // provide a default implementation for the '$' function in the test diff --git a/attack-search/__tests__/settings.test.js b/attack-search/__tests__/settings.test.js new file mode 100644 index 00000000000..011f64d4b79 --- /dev/null +++ b/attack-search/__tests__/settings.test.js @@ -0,0 +1,7 @@ +const { searchFilePaths } = require('../src/settings'); + +describe('search settings', () => { + test('loads the generated sub-technique search index', () => { + expect(searchFilePaths).toContain('sub-techniques.json'); + }); +}); diff --git a/attack-search/src/attack-index.js b/attack-search/src/attack-index.js index 184a1a8a4d6..c78120da9a3 100644 --- a/attack-search/src/attack-index.js +++ b/attack-search/src/attack-index.js @@ -21,7 +21,7 @@ module.exports = class AttackIndex { tokenize: 'strict', optimize: true, resolution: 9, - minlength: 3, + minlength: 2, context: { depth: 3, resolution: 2, diff --git a/attack-search/src/components.js b/attack-search/src/components.js index 04c9786572d..3a9b57c8d9f 100644 --- a/attack-search/src/components.js +++ b/attack-search/src/components.js @@ -24,3 +24,8 @@ module.exports.loadMoreResultsButton = $('#load-more-results-button'); // search parsing icon module.exports.searchParsingIcon = $('#search-parsing-icon'); + +// inline search filters +module.exports.searchFilters = $('#search-filters'); +module.exports.searchFiltersPanel = $('#search-filters-panel'); +module.exports.searchFiltersToggle = $('#search-filters-toggle'); diff --git a/attack-search/src/index.js b/attack-search/src/index.js index 553d2504c2c..ce519c57ae8 100644 --- a/attack-search/src/index.js +++ b/attack-search/src/index.js @@ -37,6 +37,7 @@ const openSearch = function () { // Close search overlay const closeSearch = function () { searchInput.val(''); + if (searchService) searchService.clearSession(); searchOverlay.hide(); searchOverlay.addClass('hidden'); }; @@ -172,6 +173,62 @@ loadMoreResultsButton.on('click', () => { loadMoreResultsButton.blur(); // onfocus }); +$('[data-search-filter-toggle]').on('click', () => { + if (searchService) searchService.toggleFiltersPanel(); +}); + +$('[data-search-filter-dropdown-toggle]').on('click', (e) => { + e.stopPropagation(); + if (searchService) { + searchService.toggleFilterDropdown($(e.currentTarget).data('search-filter-dropdown-toggle')); + } +}); + +$('[data-search-filter-dropdown]').on('click', (e) => { + e.stopPropagation(); +}); + +$(document).on('click', () => { + if (searchService) searchService.closeFilterDropdowns(); +}); + +$(document).on('keyup', (e) => { + if (e.key === 'Escape' && searchService) searchService.closeFilterDropdowns(); +}); + +$('[data-search-filter-page-type]').on('click', (e) => { + if (searchService) searchService.togglePageType($(e.currentTarget).data('search-filter-page-type')); +}); + +$('[data-search-filter-domain]').on('click', (e) => { + if (searchService) searchService.toggleDomain($(e.currentTarget).data('search-filter-domain')); +}); + +$('[data-search-filter-page-types-action]').on('click', (e) => { + if (!searchService) return; + const action = $(e.currentTarget).data('search-filter-page-types-action'); + + if (action === 'all') searchService.selectAllPageTypes(); + if (action === 'none') searchService.clearPageTypes(); +}); + +$('[data-search-filter-domain-action]').on('click', (e) => { + if (!searchService) return; + const action = $(e.currentTarget).data('search-filter-domain-action'); + + if (action === 'all') searchService.selectAllDomains(); + if (action === 'none') searchService.clearDomains(); +}); + +$('[data-search-filter-group-action]').on('click', (e) => { + if (!searchService) return; + const button = $(e.currentTarget); + searchService.setPageTypeGroupSelected( + button.data('search-filter-group'), + button.data('search-filter-group-action') === 'all', + ); +}); + // Add compatibility patches for Internet Explorer if (!String.prototype.includes) { String.prototype.includes = function (search, start) { @@ -195,4 +252,3 @@ console.debug('search module is loaded.'); // Initialize the search service when the module loads initializeSearchService(); - diff --git a/attack-search/src/search-service.js b/attack-search/src/search-service.js index 8b1a1e26644..9a049c5aa9c 100644 --- a/attack-search/src/search-service.js +++ b/attack-search/src/search-service.js @@ -7,7 +7,61 @@ const { IndexedDBWrapper } = require("./indexed-db-wrapper.js"); const AttackIndex = require('./attack-index.js'); // eslint-disable-next-line import/extensions -const { loadMoreResults, searchBody } = require('./components.js'); +const { + loadMoreResults, + searchBody, + searchFiltersPanel, + searchFiltersToggle, +} = require('./components.js'); + +const PAGE_TYPE_GROUPS = [ + { + key: 'core', + label: 'Core ATT&CK Objects', + pageTypes: ['matrices', 'tactics', 'techniques', 'sub-techniques'], + }, + { + key: 'defenses', + label: 'Defenses', + pageTypes: ['mitigations', 'assets', 'detectionstrategies', 'analytics', 'datacomponents'], + }, + { + key: 'cti', + label: 'CTI', + pageTypes: ['groups', 'software', 'campaigns'], + }, + { + key: 'reference', + label: 'Reference', + pageTypes: ['resources'], + }, +]; + +const PAGE_TYPE_LABELS = { + analytics: 'Analytics', + assets: 'Assets', + campaigns: 'Campaigns', + datacomponents: 'Data Components', + detectionstrategies: 'Detection Strategies', + groups: 'Groups', + matrices: 'Matrices', + mitigations: 'Mitigations', + resources: 'Resources', + software: 'Software', + 'sub-techniques': 'Sub-Techniques', + tactics: 'Tactics', + techniques: 'Techniques', +}; + +const DOMAIN_LABELS = { + enterprise: 'Enterprise', + ics: 'ICS', + mobile: 'Mobile', +}; + +const PAGE_TYPES = PAGE_TYPE_GROUPS.flatMap((group) => group.pageTypes); +const DOMAINS = Object.keys(DOMAIN_LABELS); +const FILTER_DROPDOWNS = PAGE_TYPE_GROUPS.map((group) => group.key).concat('domains'); module.exports = class SearchService { @@ -44,6 +98,17 @@ module.exports = class SearchService { this.render_container = $(`#${tag}`); + this.selectedPageTypes = new Set(PAGE_TYPES); + this.selectedDomains = new Set(DOMAINS); + this.allSearchResults = []; + this.searchResults = []; + this.filtersExpanded = false; + this.openFilterDropdown = null; + + if (this.render_container?.on) { + this.render_container.on('click', '#clear-search-filters', () => this.resetFilters()); + } + /** * The following two IndexedDBWrapper instances initialize two IndexedDB tables. Each instance corresponds to one * table. The wrapper class obfuscates the CRUD logic for interfacing with IndexedDB. @@ -98,16 +163,18 @@ module.exports = class SearchService { if (documents) { console.debug('Indexing documents: ', documents); - this.maxSearchResults = documents.length; + const searchableDocuments = documents.map(document => this.#normalizeDocumentMetadata(document)); + + this.maxSearchResults = searchableDocuments.length; // Add the data to the in-memory FlexSearch instance - this.attackIndex.addBulk(documents); + this.attackIndex.addBulk(searchableDocuments); console.debug('Backing up search index...'); // Backup the in-memory FlexSearch index for later restoration await this.backupSearchIndex(); - await this.contentDb.bulkPut(documents, 100); + await this.contentDb.bulkPut(searchableDocuments, 100); console.debug('Backup of search index completed.'); } else { @@ -177,7 +244,9 @@ module.exports = class SearchService { const docs = await Promise.all(getPromises); // Filter out null or undefined documents - return docs.filter(doc => doc !== null && doc !== undefined); + return docs + .filter(doc => doc !== null && doc !== undefined) + .map(doc => this.#normalizeDocumentMetadata(doc)); } /** @@ -193,6 +262,15 @@ module.exports = class SearchService { this.offset = 0; this.#cleanTheQuery(query); this.render_container.html(''); + + if (this.currentQuery.clean === '') { + this.allSearchResults = []; + this.searchResults = []; + this.#renderSearchResults([]); + this.#updateFilterControls(); + return; + } + const results = await this.attackIndex.search(this.currentQuery.clean, ["title", "content"], this.maxSearchResults); console.debug('search index results: ', results); @@ -215,9 +293,8 @@ module.exports = class SearchService { * ] */ - this.searchResults = await this.#setSearchResults(results); - const firstPage = this.searchResults.slice(0, this.pageLimit); - this.#renderSearchResults(firstPage); + this.allSearchResults = await this.#setSearchResults(results); + this.#renderFilteredSearchResults(); } /** @@ -249,27 +326,27 @@ module.exports = class SearchService { #renderSearchResults(page) { if (page.length > 0) { // Render the search results - searchBody.show(); + searchBody?.show?.(); const self = this; let resultHTML = page.map((result) => self.#resultToHTML(result)); resultHTML = resultHTML.join(''); - this.render_container.append(resultHTML); + if (this.render_container?.append) this.render_container.append(resultHTML); // if there are more pages to show if (this.offset + this.pageLimit < this.searchResults.length) { - loadMoreResults.show(); + loadMoreResults?.show?.(); } else { - loadMoreResults.hide(); + loadMoreResults?.hide?.(); } } else if (this.currentQuery.clean !== '') { // search with no results - searchBody.show(); - this.render_container.html(`
no results
`); - loadMoreResults.hide(); + searchBody?.show?.(); + if (this.render_container?.html) this.render_container.html(this.#emptyResultsHTML()); + loadMoreResults?.hide?.(); } else { // query for empty string - searchBody.hide(); + searchBody?.hide?.(); } } @@ -320,6 +397,196 @@ module.exports = class SearchService { return titleDocuments.concat(contentDocuments); } + /** + * Filters a result set using the selected page type and domain filters. + * @param {Array} documents - Search results to filter. + * @returns {Array} Filtered documents. + */ + applyFilters(documents) { + return documents + .map(document => this.#normalizeDocumentMetadata(document)) + .filter(document => this.#matchesSelectedPageTypes(document) && this.#matchesSelectedDomains(document)); + } + + /** + * Sets selected page type filters. + * @param {Array} pageTypes - Page type keys to select. + */ + setSelectedPageTypes(pageTypes) { + this.selectedPageTypes = new Set(pageTypes.filter(pageType => PAGE_TYPES.includes(pageType))); + this.#renderFilteredSearchResults(); + } + + /** + * Sets selected domain filters. + * @param {Array} domains - Domain keys to select. + */ + setSelectedDomains(domains) { + this.selectedDomains = new Set(domains.filter(domain => DOMAINS.includes(domain))); + this.#renderFilteredSearchResults(); + } + + /** + * Toggles one page type. + * @param {string} pageType - Page type key to toggle. + */ + togglePageType(pageType) { + if (!PAGE_TYPES.includes(pageType)) return; + this.#toggleSetValue(this.selectedPageTypes, pageType); + this.#renderFilteredSearchResults(); + } + + /** + * Toggles one domain. + * @param {string} domain - Domain key to toggle. + */ + toggleDomain(domain) { + if (!DOMAINS.includes(domain)) return; + this.#toggleSetValue(this.selectedDomains, domain); + this.#renderFilteredSearchResults(); + } + + /** + * Selects or clears all page types in a group. + * @param {string} groupKey - Page type group key. + * @param {boolean} selected - Whether group values should be selected. + */ + setPageTypeGroupSelected(groupKey, selected) { + const group = PAGE_TYPE_GROUPS.find(candidate => candidate.key === groupKey); + if (!group) return; + + group.pageTypes.forEach((pageType) => { + if (selected) { + this.selectedPageTypes.add(pageType); + } else { + this.selectedPageTypes.delete(pageType); + } + }); + this.#renderFilteredSearchResults(); + } + + /** + * Selects every page type. + */ + selectAllPageTypes() { + this.selectedPageTypes = new Set(PAGE_TYPES); + this.#renderFilteredSearchResults(); + } + + /** + * Clears every page type. + */ + clearPageTypes() { + this.selectedPageTypes = new Set(); + this.#renderFilteredSearchResults(); + } + + /** + * Selects every domain. + */ + selectAllDomains() { + this.selectedDomains = new Set(DOMAINS); + this.#renderFilteredSearchResults(); + } + + /** + * Clears every domain. + */ + clearDomains() { + this.selectedDomains = new Set(); + this.#renderFilteredSearchResults(); + } + + /** + * Resets all filters to their default all-selected state. + */ + resetFilters() { + this.selectedPageTypes = new Set(PAGE_TYPES); + this.selectedDomains = new Set(DOMAINS); + this.#renderFilteredSearchResults(); + } + + /** + * Clears query-scoped search state and resets filters to the default all-selected state. + */ + clearSession() { + this.currentQuery.clean = ''; + this.allSearchResults = []; + this.searchResults = []; + this.selectedPageTypes = new Set(PAGE_TYPES); + this.selectedDomains = new Set(DOMAINS); + this.filtersExpanded = false; + this.openFilterDropdown = null; + if (this.render_container?.html) this.render_container.html(''); + this.#updateFilterControls(); + searchBody?.hide?.(); + loadMoreResults?.hide?.(); + } + + /** + * Opens or closes one compact filter dropdown. + * @param {string} dropdownKey - Filter dropdown key to toggle. + */ + toggleFilterDropdown(dropdownKey) { + if (!FILTER_DROPDOWNS.includes(dropdownKey)) return; + + this.openFilterDropdown = this.openFilterDropdown === dropdownKey ? null : dropdownKey; + if (this.openFilterDropdown) this.filtersExpanded = false; + this.#updateFilterControls(); + } + + /** + * Closes any compact filter dropdown. + */ + closeFilterDropdowns() { + if (!this.openFilterDropdown) return; + + this.openFilterDropdown = null; + this.#updateFilterControls(); + } + + /** + * Gets the currently open compact filter dropdown. + * @returns {?string} Current dropdown key, or null if every compact dropdown is closed. + */ + getOpenFilterDropdown() { + return this.openFilterDropdown; + } + + /** + * Opens or closes the inline filters panel. + */ + toggleFiltersPanel() { + this.filtersExpanded = !this.filtersExpanded; + if (this.filtersExpanded) this.openFilterDropdown = null; + this.#updateFilterControls(); + } + + /** + * Counts filter matches for the current query result set. + * @param {Array} documents - Search results to count. + * @returns {{pageTypes: Object, domains: Object}} Filter counts. + */ + getFilterCounts(documents) { + const normalizedDocuments = documents.map(document => this.#normalizeDocumentMetadata(document)); + const pageTypes = {}; + const domains = {}; + + PAGE_TYPES.forEach((pageType) => { + pageTypes[pageType] = normalizedDocuments.filter(document => ( + document.pageType === pageType && this.#matchesSelectedDomains(document) + )).length; + }); + + DOMAINS.forEach((domain) => { + domains[domain] = normalizedDocuments.filter(document => ( + this.#matchesSelectedPageTypes(document) && document.domains.includes(domain) + )).length; + }); + + return { pageTypes, domains }; + } + /** * Asynchronously loads and renders more search results by increasing the current offset. * This method is used for paginating the search results. @@ -334,6 +601,120 @@ module.exports = class SearchService { this.#renderSearchResults(nextPage); } + #renderFilteredSearchResults() { + this.offset = 0; + if (this.render_container?.html) this.render_container.html(''); + this.searchResults = this.applyFilters(this.allSearchResults); + this.#updateFilterControls(); + this.#renderSearchResults(this.searchResults.slice(0, this.pageLimit)); + } + + #matchesSelectedPageTypes(document) { + return this.selectedPageTypes.has(document.pageType); + } + + #matchesSelectedDomains(document) { + if (this.selectedDomains.size === DOMAINS.length) return true; + if (this.selectedDomains.size === 0) return false; + return document.domains.some(domain => this.selectedDomains.has(domain)); + } + + #toggleSetValue(targetSet, value) { + if (targetSet.has(value)) { + targetSet.delete(value); + } else { + targetSet.add(value); + } + } + + #updateFilterControls() { + const counts = this.getFilterCounts(this.allSearchResults); + + this.#setElementText( + $('[data-search-filter-summary="page-types"]'), + this.#selectedSummary(this.selectedPageTypes, PAGE_TYPES), + ); + this.#setElementText( + $('[data-search-filter-summary="domains"]'), + this.#selectedSummary(this.selectedDomains, DOMAINS), + ); + PAGE_TYPE_GROUPS.forEach((group) => { + this.#setElementText( + $(`[data-search-filter-summary="group-${group.key}"]`), + this.#selectedSummary( + new Set(group.pageTypes.filter((pageType) => this.selectedPageTypes.has(pageType))), + group.pageTypes, + ), + ); + }); + + PAGE_TYPES.forEach((pageType) => { + const button = $(`[data-search-filter-page-type="${pageType}"]`); + this.#toggleElementClass(button, 'selected', this.selectedPageTypes.has(pageType)); + this.#setElementAttribute(button, 'aria-pressed', this.selectedPageTypes.has(pageType).toString()); + this.#setElementText(button?.find?.('.search-filter-count'), counts.pageTypes[pageType]); + }); + + DOMAINS.forEach((domain) => { + const button = $(`[data-search-filter-domain="${domain}"]`); + this.#toggleElementClass(button, 'selected', this.selectedDomains.has(domain)); + this.#setElementAttribute(button, 'aria-pressed', this.selectedDomains.has(domain).toString()); + this.#setElementText(button?.find?.('.search-filter-count'), counts.domains[domain]); + }); + + searchFiltersPanel?.toggle?.(this.filtersExpanded); + this.#setElementAttribute(searchFiltersPanel, 'aria-hidden', (!this.filtersExpanded).toString()); + this.#setElementText(searchFiltersToggle, this.filtersExpanded ? 'Hide all Filters' : 'Show all Filters'); + this.#setElementAttribute(searchFiltersToggle, 'aria-expanded', this.filtersExpanded.toString()); + + FILTER_DROPDOWNS.forEach((dropdownKey) => { + const isOpen = this.openFilterDropdown === dropdownKey; + const toggle = $(`[data-search-filter-dropdown-toggle="${dropdownKey}"]`); + const dropdown = $(`[data-search-filter-dropdown="${dropdownKey}"]`); + + this.#toggleElementClass(toggle, 'open', isOpen); + this.#setElementAttribute(toggle, 'aria-expanded', isOpen.toString()); + dropdown?.toggle?.(isOpen); + this.#setElementAttribute(dropdown, 'aria-hidden', (!isOpen).toString()); + }); + } + + #setElementText(element, value) { + if (element?.text) element.text(value); + } + + #setElementAttribute(element, name, value) { + if (element?.attr) element.attr(name, value); + } + + #toggleElementClass(element, className, value) { + if (element?.toggleClass) element.toggleClass(className, value); + } + + #selectedSummary(selectedValues, allValues) { + if (selectedValues.size === allValues.length) return 'All'; + return `${selectedValues.size} selected`; + } + + #emptyResultsHTML() { + const filtersAreActive = ( + this.selectedPageTypes.size !== PAGE_TYPES.length || this.selectedDomains.size !== DOMAINS.length + ); + if (!filtersAreActive) return '
no results
'; + + return ` +
+
no results
+
+ Active filters may be limiting results. + +
+
+ `; + } + /** * Cleans and processes the search query by trimming white spaces, escaping special characters, and creating * regular expressions for each word in the query. The processed query is stored in the `this.currentQuery` object. @@ -348,13 +729,13 @@ module.exports = class SearchService { this.currentQuery.clean = query.trim(); // build joined string - const joined = `(${this.currentQuery.clean.split(' ').join('|')})`; + const joined = `(${this.currentQuery.clean.split(/\s+/).join('|')})`; this.currentQuery.joined = new RegExp(joined, 'gi'); // Build regex for each word // remove double spaces which causes query to match on every 0 length string and flip out - const escaped = this.currentQuery.clean.replace(/\s+/, ' '); + const escaped = this.currentQuery.clean.replace(/\s+/g, ' '); // The following map code is modifying the current_query object by setting its words property to an array of // objects. Each object in the array represents a word that was entered as part of a search query, along with a @@ -521,6 +902,7 @@ module.exports = class SearchService { + ${this.#resultBadgesHTML(result)}
${preview}
@@ -528,4 +910,46 @@ module.exports = class SearchService { `; // end template } + #resultBadgesHTML(result) { + const badges = []; + const pageTypeLabel = PAGE_TYPE_LABELS[result.pageType]; + + if (pageTypeLabel) badges.push(pageTypeLabel); + result.domains.forEach((domain) => { + if (DOMAIN_LABELS[domain]) badges.push(DOMAIN_LABELS[domain]); + }); + + if (badges.length === 0) return ''; + + return ` +
+ ${badges.map(badge => `${badge}`).join('')} +
+ `; + } + + #normalizeDocumentMetadata(document) { + return { + ...document, + pageType: PAGE_TYPES.includes(document.pageType) ? document.pageType : this.#inferPageType(document.path), + domains: Array.isArray(document.domains) ? document.domains.filter(domain => DOMAINS.includes(domain)) : [], + }; + } + + #inferPageType(path = '') { + if (/^\/techniques\/[^/]+\/[^/]+\/index\.html$/.test(path)) return 'sub-techniques'; + if (path.startsWith('/analytics/')) return 'analytics'; + if (path.startsWith('/assets/')) return 'assets'; + if (path.startsWith('/campaigns/')) return 'campaigns'; + if (path.startsWith('/datacomponents/')) return 'datacomponents'; + if (path.startsWith('/detectionstrategies/')) return 'detectionstrategies'; + if (path.startsWith('/groups/')) return 'groups'; + if (path.startsWith('/matrices/')) return 'matrices'; + if (path.startsWith('/mitigations/')) return 'mitigations'; + if (path.startsWith('/software/')) return 'software'; + if (path.startsWith('/tactics/')) return 'tactics'; + if (path.startsWith('/techniques/')) return 'techniques'; + return 'resources'; + } + } diff --git a/attack-search/src/settings.js b/attack-search/src/settings.js index b71a9f3a023..793605bdfe1 100644 --- a/attack-search/src/settings.js +++ b/attack-search/src/settings.js @@ -6,9 +6,10 @@ const searchFilePaths = [ 'datacomponents.json', 'groups.json', 'matrices.json', - 'misc.json', 'mitigations.json', + 'resources.json', 'software.json', + 'sub-techniques.json', 'tactics.json', 'techniques.json', 'detectionstrategies.json', diff --git a/attack-style/components/_search.scss b/attack-style/components/_search.scss index ec18d876a75..0bef86234c4 100644 --- a/attack-style/components/_search.scss +++ b/attack-style/components/_search.scss @@ -87,6 +87,113 @@ } } + .search-filters { + flex-shrink: 0; + padding: 0 50px 12px; + + button { + min-height: 36px; + border: 1px solid color-functions.border-color(body); + border-radius: 4px; + background: color-functions.color(body); + color: color-functions.on-color(body); + cursor: pointer; + } + + .search-filter-summary { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .search-filter-summary-chip { + padding: 5px 12px; + font-weight: 600; + + &.open { + border-color: color-functions.color(active); + } + } + + .search-filter-dropdown { + position: relative; + } + + .search-filter-dropdown-menu { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 2; + width: max-content; + min-width: 240px; + max-width: min(420px, calc(100vw - 100px)); + padding: 12px; + border: 1px solid color-functions.border-color(body); + border-radius: 8px; + background: color-functions.color(body); + box-shadow: 0 8px 24px rgb(0 0 0 / 15%); + } + + .search-filters-panel { + margin-top: 12px; + padding: 14px; + border: 1px solid color-functions.border-color(body); + border-radius: 8px; + } + + .search-filter-heading, + .search-filter-group-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; + font-weight: 700; + } + + .search-filter-heading { + text-transform: uppercase; + font-size: 0.8rem; + } + + .search-filter-group { + margin-bottom: 14px; + } + + .search-filter-actions { + display: inline-flex; + gap: 4px; + + button { + min-height: 28px; + padding: 2px 8px; + font-size: 0.8rem; + } + } + + .search-filter-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .search-filter-chips button { + padding: 5px 10px; + + &.selected { + border-color: color-functions.color(active); + background: color-functions.color(active); + color: color-functions.on-color(active); + } + } + + .search-filter-count { + margin-left: 4px; + font-size: 0.8em; + opacity: 0.8; + } + } + .search-body { flex: 1 1 auto; padding: 0 50px; @@ -98,6 +205,28 @@ .search-result:first-child { margin-top: 1.5rem; } + + .search-result-badges { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin: 4px 0 6px; + } + + .search-result-badge { + padding: 1px 6px; + border: 1px solid color-functions.border-color(body); + border-radius: 4px; + font-size: 0.75rem; + font-weight: 700; + } + + .search-no-results .preview { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + } } .load-more-results { @@ -122,11 +251,52 @@ // mobile display search overrides @media screen and (min-width: 1px) and (max-width: 767.8px) { + .overlay.search { + padding: 15px !important; + } + + .overlay.search .overlay-inner { + border-radius: 12px !important; + } + .search-input input { font-size: 20px !important; line-height: 25px !important; } + .overlay.search .search-filters, + .overlay.search .search-body { + padding-left: 15px !important; + padding-right: 15px !important; + } + + .overlay.search .search-filters button { + min-height: 40px; + } + + .overlay.search .search-filter-dropdown { + position: static !important; + } + + .overlay.search .search-filter-dropdown-menu { + left: 15px !important; + right: 15px !important; + width: auto !important; + min-width: 0 !important; + max-width: none !important; + } + + .overlay.search .search-filters-panel { + padding: 10px !important; + } + + .overlay.search .search-filter-heading, + .overlay.search .search-filter-group-heading { + align-items: flex-start !important; + flex-direction: column !important; + gap: 5px !important; + } + .close-search-icon { font-size: 25px !important; line-height: 25px !important; diff --git a/attack-theme/static/images/advisory-council/adam-pennington.jpg b/attack-theme/static/images/advisory-council/adam-pennington.jpg new file mode 100644 index 00000000000..535ea2244fb Binary files /dev/null and b/attack-theme/static/images/advisory-council/adam-pennington.jpg differ diff --git a/attack-theme/static/images/advisory-council/brian-mohr.jpg b/attack-theme/static/images/advisory-council/brian-mohr.jpg new file mode 100644 index 00000000000..ee188c234b0 Binary files /dev/null and b/attack-theme/static/images/advisory-council/brian-mohr.jpg differ diff --git a/attack-theme/static/images/advisory-council/charles-clancy.jpg b/attack-theme/static/images/advisory-council/charles-clancy.jpg new file mode 100644 index 00000000000..fa85222baa7 Binary files /dev/null and b/attack-theme/static/images/advisory-council/charles-clancy.jpg differ diff --git a/attack-theme/static/images/advisory-council/eric-stride.jpg b/attack-theme/static/images/advisory-council/eric-stride.jpg new file mode 100644 index 00000000000..7923f3d5e8f Binary files /dev/null and b/attack-theme/static/images/advisory-council/eric-stride.jpg differ diff --git a/attack-theme/static/images/advisory-council/freddy-dezeure.png b/attack-theme/static/images/advisory-council/freddy-dezeure.png new file mode 100644 index 00000000000..0cc427f83a8 Binary files /dev/null and b/attack-theme/static/images/advisory-council/freddy-dezeure.png differ diff --git a/attack-theme/static/images/advisory-council/gene-spafford.jpg b/attack-theme/static/images/advisory-council/gene-spafford.jpg new file mode 100644 index 00000000000..846657e53bd Binary files /dev/null and b/attack-theme/static/images/advisory-council/gene-spafford.jpg differ diff --git a/attack-theme/static/images/advisory-council/kimberly-goody.jpg b/attack-theme/static/images/advisory-council/kimberly-goody.jpg new file mode 100644 index 00000000000..c57d2122175 Binary files /dev/null and b/attack-theme/static/images/advisory-council/kimberly-goody.jpg differ diff --git a/attack-theme/static/images/advisory-council/krysta-horocofsky.jpeg b/attack-theme/static/images/advisory-council/krysta-horocofsky.jpeg new file mode 100644 index 00000000000..a4fcdf125ba Binary files /dev/null and b/attack-theme/static/images/advisory-council/krysta-horocofsky.jpeg differ diff --git a/attack-theme/static/images/advisory-council/richard-struse.jpeg b/attack-theme/static/images/advisory-council/richard-struse.jpeg new file mode 100644 index 00000000000..2a6bf7a4b4e Binary files /dev/null and b/attack-theme/static/images/advisory-council/richard-struse.jpeg differ diff --git a/attack-theme/static/images/advisory-council/ryan-miller.jpg b/attack-theme/static/images/advisory-council/ryan-miller.jpg new file mode 100644 index 00000000000..841e29defa2 Binary files /dev/null and b/attack-theme/static/images/advisory-council/ryan-miller.jpg differ diff --git a/attack-theme/static/images/missing-person-placeholder.svg b/attack-theme/static/images/missing-person-placeholder.svg new file mode 100644 index 00000000000..771aacec7d5 --- /dev/null +++ b/attack-theme/static/images/missing-person-placeholder.svg @@ -0,0 +1,8 @@ + + Missing person portrait placeholder + Generic silhouette portrait placeholder for member headshots. + + + + + diff --git a/attack-theme/static/style-attack.css b/attack-theme/static/style-attack.css index 770c8985e75..7aeedb89620 100644 --- a/attack-theme/static/style-attack.css +++ b/attack-theme/static/style-attack.css @@ -1910,6 +1910,96 @@ div#sidebars { .overlay.search .overlay-inner .search-header .search-icons .close-search-icon:hover { opacity: 1; } +.overlay.search .overlay-inner .search-filters { + flex-shrink: 0; + padding: 0 50px 12px; +} +.overlay.search .overlay-inner .search-filters button { + min-height: 36px; + border: 1px solid rgb(223.125, 223.125, 223.125); + border-radius: 4px; + background: white; + color: #39434c; + cursor: pointer; +} +.overlay.search .overlay-inner .search-filters .search-filter-summary { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.overlay.search .overlay-inner .search-filters .search-filter-summary-chip { + padding: 5px 12px; + font-weight: 600; +} +.overlay.search .overlay-inner .search-filters .search-filter-summary-chip.open { + border-color: #0156b3; +} +.overlay.search .overlay-inner .search-filters .search-filter-dropdown { + position: relative; +} +.overlay.search .overlay-inner .search-filters .search-filter-dropdown-menu { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 2; + width: max-content; + min-width: 240px; + max-width: min(420px, 100vw - 100px); + padding: 12px; + border: 1px solid rgb(223.125, 223.125, 223.125); + border-radius: 8px; + background: white; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); +} +.overlay.search .overlay-inner .search-filters .search-filters-panel { + margin-top: 12px; + padding: 14px; + border: 1px solid rgb(223.125, 223.125, 223.125); + border-radius: 8px; +} +.overlay.search .overlay-inner .search-filters .search-filter-heading, +.overlay.search .overlay-inner .search-filters .search-filter-group-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; + font-weight: 700; +} +.overlay.search .overlay-inner .search-filters .search-filter-heading { + text-transform: uppercase; + font-size: 0.8rem; +} +.overlay.search .overlay-inner .search-filters .search-filter-group { + margin-bottom: 14px; +} +.overlay.search .overlay-inner .search-filters .search-filter-actions { + display: inline-flex; + gap: 4px; +} +.overlay.search .overlay-inner .search-filters .search-filter-actions button { + min-height: 28px; + padding: 2px 8px; + font-size: 0.8rem; +} +.overlay.search .overlay-inner .search-filters .search-filter-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.overlay.search .overlay-inner .search-filters .search-filter-chips button { + padding: 5px 10px; +} +.overlay.search .overlay-inner .search-filters .search-filter-chips button.selected { + border-color: #0156b3; + background: #0156b3; + color: #eaeaea; +} +.overlay.search .overlay-inner .search-filters .search-filter-count { + margin-left: 4px; + font-size: 0.8em; + opacity: 0.8; +} .overlay.search .overlay-inner .search-body { flex: 1 1 auto; padding: 0 50px; @@ -1920,6 +2010,25 @@ div#sidebars { .overlay.search .overlay-inner .search-body .results .search-result:first-child { margin-top: 1.5rem; } +.overlay.search .overlay-inner .search-body .results .search-result-badges { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin: 4px 0 6px; +} +.overlay.search .overlay-inner .search-body .results .search-result-badge { + padding: 1px 6px; + border: 1px solid rgb(223.125, 223.125, 223.125); + border-radius: 4px; + font-size: 0.75rem; + font-weight: 700; +} +.overlay.search .overlay-inner .search-body .results .search-no-results .preview { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} .overlay.search .overlay-inner .search-body .load-more-results { text-align: center; } @@ -1935,10 +2044,43 @@ div#sidebars { } } @media screen and (min-width: 1px) and (max-width: 767.8px) { + .overlay.search { + padding: 15px !important; + } + .overlay.search .overlay-inner { + border-radius: 12px !important; + } .search-input input { font-size: 20px !important; line-height: 25px !important; } + .overlay.search .search-filters, + .overlay.search .search-body { + padding-left: 15px !important; + padding-right: 15px !important; + } + .overlay.search .search-filters button { + min-height: 40px; + } + .overlay.search .search-filter-dropdown { + position: static !important; + } + .overlay.search .search-filter-dropdown-menu { + left: 15px !important; + right: 15px !important; + width: auto !important; + min-width: 0 !important; + max-width: none !important; + } + .overlay.search .search-filters-panel { + padding: 10px !important; + } + .overlay.search .search-filter-heading, + .overlay.search .search-filter-group-heading { + align-items: flex-start !important; + flex-direction: column !important; + gap: 5px !important; + } .close-search-icon { font-size: 25px !important; line-height: 25px !important; diff --git a/attack-theme/static/style-user.css b/attack-theme/static/style-user.css index 814eacec1cf..2436ceab11c 100644 --- a/attack-theme/static/style-user.css +++ b/attack-theme/static/style-user.css @@ -1910,6 +1910,96 @@ div#sidebars { .overlay.search .overlay-inner .search-header .search-icons .close-search-icon:hover { opacity: 1; } +.overlay.search .overlay-inner .search-filters { + flex-shrink: 0; + padding: 0 50px 12px; +} +.overlay.search .overlay-inner .search-filters button { + min-height: 36px; + border: 1px solid rgb(223.125, 223.125, 223.125); + border-radius: 4px; + background: white; + color: #39434c; + cursor: pointer; +} +.overlay.search .overlay-inner .search-filters .search-filter-summary { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.overlay.search .overlay-inner .search-filters .search-filter-summary-chip { + padding: 5px 12px; + font-weight: 600; +} +.overlay.search .overlay-inner .search-filters .search-filter-summary-chip.open { + border-color: #303435; +} +.overlay.search .overlay-inner .search-filters .search-filter-dropdown { + position: relative; +} +.overlay.search .overlay-inner .search-filters .search-filter-dropdown-menu { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 2; + width: max-content; + min-width: 240px; + max-width: min(420px, 100vw - 100px); + padding: 12px; + border: 1px solid rgb(223.125, 223.125, 223.125); + border-radius: 8px; + background: white; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); +} +.overlay.search .overlay-inner .search-filters .search-filters-panel { + margin-top: 12px; + padding: 14px; + border: 1px solid rgb(223.125, 223.125, 223.125); + border-radius: 8px; +} +.overlay.search .overlay-inner .search-filters .search-filter-heading, +.overlay.search .overlay-inner .search-filters .search-filter-group-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; + font-weight: 700; +} +.overlay.search .overlay-inner .search-filters .search-filter-heading { + text-transform: uppercase; + font-size: 0.8rem; +} +.overlay.search .overlay-inner .search-filters .search-filter-group { + margin-bottom: 14px; +} +.overlay.search .overlay-inner .search-filters .search-filter-actions { + display: inline-flex; + gap: 4px; +} +.overlay.search .overlay-inner .search-filters .search-filter-actions button { + min-height: 28px; + padding: 2px 8px; + font-size: 0.8rem; +} +.overlay.search .overlay-inner .search-filters .search-filter-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.overlay.search .overlay-inner .search-filters .search-filter-chips button { + padding: 5px 10px; +} +.overlay.search .overlay-inner .search-filters .search-filter-chips button.selected { + border-color: #303435; + background: #303435; + color: #eaeaea; +} +.overlay.search .overlay-inner .search-filters .search-filter-count { + margin-left: 4px; + font-size: 0.8em; + opacity: 0.8; +} .overlay.search .overlay-inner .search-body { flex: 1 1 auto; padding: 0 50px; @@ -1920,6 +2010,25 @@ div#sidebars { .overlay.search .overlay-inner .search-body .results .search-result:first-child { margin-top: 1.5rem; } +.overlay.search .overlay-inner .search-body .results .search-result-badges { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin: 4px 0 6px; +} +.overlay.search .overlay-inner .search-body .results .search-result-badge { + padding: 1px 6px; + border: 1px solid rgb(223.125, 223.125, 223.125); + border-radius: 4px; + font-size: 0.75rem; + font-weight: 700; +} +.overlay.search .overlay-inner .search-body .results .search-no-results .preview { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} .overlay.search .overlay-inner .search-body .load-more-results { text-align: center; } @@ -1935,10 +2044,43 @@ div#sidebars { } } @media screen and (min-width: 1px) and (max-width: 767.8px) { + .overlay.search { + padding: 15px !important; + } + .overlay.search .overlay-inner { + border-radius: 12px !important; + } .search-input input { font-size: 20px !important; line-height: 25px !important; } + .overlay.search .search-filters, + .overlay.search .search-body { + padding-left: 15px !important; + padding-right: 15px !important; + } + .overlay.search .search-filters button { + min-height: 40px; + } + .overlay.search .search-filter-dropdown { + position: static !important; + } + .overlay.search .search-filter-dropdown-menu { + left: 15px !important; + right: 15px !important; + width: auto !important; + min-width: 0 !important; + max-width: none !important; + } + .overlay.search .search-filters-panel { + padding: 10px !important; + } + .overlay.search .search-filter-heading, + .overlay.search .search-filter-group-heading { + align-items: flex-start !important; + flex-direction: column !important; + gap: 5px !important; + } .close-search-icon { font-size: 25px !important; line-height: 25px !important; diff --git a/attack-theme/templates/general/base-template.html b/attack-theme/templates/general/base-template.html index 78c9fb2c9d4..f5e0e7e72f5 100644 --- a/attack-theme/templates/general/base-template.html +++ b/attack-theme/templates/general/base-template.html @@ -116,7 +116,7 @@
- © 2015 - 2025, The MITRE Corporation. MITRE ATT&CK and ATT&CK are registered trademarks of The MITRE Corporation. + © 2015 - 2026, The MITRE Corporation. MITRE ATT&CK and ATT&CK are registered trademarks of The MITRE Corporation.
diff --git a/attack-theme/templates/macros/navigation_menu.html b/attack-theme/templates/macros/navigation_menu.html index b77fd46285b..c842b4d1027 100644 --- a/attack-theme/templates/macros/navigation_menu.html +++ b/attack-theme/templates/macros/navigation_menu.html @@ -1,6 +1,6 @@ {% macro navigation_menu(menu, logo_header, output_file) -%}