diff --git a/README.md b/README.md index 27ab2a93b..37da80903 100755 --- a/README.md +++ b/README.md @@ -169,6 +169,23 @@ Example custom context menu objects: } ``` +By default a custom context menu option is shown for every technique. To restrict an option to specific techniques, add a `limit_techniques` array to the object. Each entry can be either: + +* a bare ATT&CK ID string, e.g. `"T1059"`, which applies the option to that technique under any tactic it appears in, or +* an object of the form `{ "id": "T1059", "tactic": "execution" }`, which applies the option to that technique only when viewed under the given tactic (tactic in shortname/phase-name format, same convention used by the optional `tactic` field on technique objects in the [layer format](layers/README.md)). This is useful for multi-tactic techniques where the option should only make sense under one of their tactics. + +If `limit_techniques` is omitted (or empty), the option applies to every technique, matching the previous default behavior. + +Example restricted to one technique under all of its tactics, and one technique under a single tactic: + +```json +{ + "label": "example restricted option", + "url": "https://test.com", + "limit_techniques": ["T1098", { "id": "T1059", "tactic": "execution" }] +} +``` + ## Methods for loading content ### Loading content from a Collection Index diff --git a/nav-app/src/app/classes/context-menu-item.spec.ts b/nav-app/src/app/classes/context-menu-item.spec.ts new file mode 100644 index 000000000..879f58d68 --- /dev/null +++ b/nav-app/src/app/classes/context-menu-item.spec.ts @@ -0,0 +1,60 @@ +import { ContextMenuItem } from './context-menu-item'; +import { Technique } from './stix/technique'; +import { Tactic } from './stix/tactic'; + +// appliesTo() only reads technique.attackID and tactic.shortname, so lightweight +// fixtures are cast rather than constructing full StixObject/DataService graphs. +function fakeTechnique(attackID: string): Technique { + return { attackID } as unknown as Technique; +} + +function fakeTactic(shortname: string): Tactic { + return { shortname } as unknown as Tactic; +} + +describe('ContextMenuItem.appliesTo', () => { + const t1059 = fakeTechnique('T1059'); + const t1098 = fakeTechnique('T1098'); + const execution = fakeTactic('execution'); + const persistence = fakeTactic('persistence'); + + it('applies to every technique when limit_techniques is not set', () => { + const item = new ContextMenuItem('label', 'url'); + expect(item.appliesTo(t1059, execution)).toBeTrue(); + expect(item.appliesTo(t1098, persistence)).toBeTrue(); + }); + + it('applies to every technique when limit_techniques is an empty array', () => { + const item = new ContextMenuItem('label', 'url', null, []); + expect(item.appliesTo(t1059, execution)).toBeTrue(); + }); + + it('matches a bare ATT&CK ID entry regardless of tactic', () => { + const item = new ContextMenuItem('label', 'url', null, ['T1059']); + expect(item.appliesTo(t1059, execution)).toBeTrue(); + expect(item.appliesTo(t1059, persistence)).toBeTrue(); + }); + + it('does not match a bare ATT&CK ID entry for a different technique', () => { + const item = new ContextMenuItem('label', 'url', null, ['T1059']); + expect(item.appliesTo(t1098, execution)).toBeFalse(); + }); + + it('matches an { id, tactic } entry only under the specified tactic', () => { + const item = new ContextMenuItem('label', 'url', null, [{ id: 'T1059', tactic: 'execution' }]); + expect(item.appliesTo(t1059, execution)).toBeTrue(); + expect(item.appliesTo(t1059, persistence)).toBeFalse(); + }); + + it('does not match an { id, tactic } entry for a different technique', () => { + const item = new ContextMenuItem('label', 'url', null, [{ id: 'T1059', tactic: 'execution' }]); + expect(item.appliesTo(t1098, execution)).toBeFalse(); + }); + + it('supports a mixed list of bare IDs and { id, tactic } entries', () => { + const item = new ContextMenuItem('label', 'url', null, ['T1098', { id: 'T1059', tactic: 'execution' }]); + expect(item.appliesTo(t1098, persistence)).toBeTrue(); // bare ID, any tactic + expect(item.appliesTo(t1059, execution)).toBeTrue(); // scoped match + expect(item.appliesTo(t1059, persistence)).toBeFalse(); // scoped, wrong tactic + }); +}); diff --git a/nav-app/src/app/classes/context-menu-item.ts b/nav-app/src/app/classes/context-menu-item.ts index 0a208b5b3..ffa68f611 100644 --- a/nav-app/src/app/classes/context-menu-item.ts +++ b/nav-app/src/app/classes/context-menu-item.ts @@ -1,15 +1,47 @@ import { Tactic } from './stix/tactic'; import { Technique } from './stix/technique'; +/** + * A single entry in a ContextMenuItem's limit_techniques filter. + * A bare ATT&CK ID (e.g. "T1059") applies to that technique under any tactic it + * appears in. An { id, tactic } pair scopes the entry to that technique under + * one specific tactic (tactic given in shortname/phase-name format, matching + * the optional `tactic` field on technique objects in the layer format). + */ +export type ContextMenuTechniqueFilter = string | { id: string; tactic: string }; + export class ContextMenuItem { public readonly label: string; private readonly url: string; private readonly subtechnique_url: string; + private readonly limit_techniques: ContextMenuTechniqueFilter[]; - constructor(label, url, subtechnique_url = null) { + constructor(label, url, subtechnique_url = null, limit_techniques: ContextMenuTechniqueFilter[] = null) { this.label = label; this.url = url; this.subtechnique_url = subtechnique_url; + this.limit_techniques = limit_techniques; + } + + /** + * Determine whether this custom context menu item should be shown for the + * given technique/tactic combination. + * + * If limit_techniques is not set (or empty), the item applies to every + * technique, preserving the existing default behavior. Otherwise the item + * only applies if the technique matches a bare-ID entry, or matches an + * { id, tactic } entry under the specific tactic being viewed. + * @param {Technique} technique the technique the context menu was opened on + * @param {Tactic} tactic the tactic column the technique is being viewed under + * @returns {boolean} true if this item should be shown for this technique/tactic + */ + public appliesTo(technique: Technique, tactic: Tactic): boolean { + if (!this.limit_techniques || this.limit_techniques.length === 0) return true; + + return this.limit_techniques.some((entry) => { + if (typeof entry === 'string') return entry === technique.attackID; + return entry.id === technique.attackID && entry.tactic === tactic.shortname; + }); } public getReplacedURL(technique: Technique, tactic: Tactic): string { diff --git a/nav-app/src/app/matrix/technique-cell/contextmenu/contextmenu.component.html b/nav-app/src/app/matrix/technique-cell/contextmenu/contextmenu.component.html index a0262b0e2..a8c52f7a0 100644 --- a/nav-app/src/app/matrix/technique-cell/contextmenu/contextmenu.component.html +++ b/nav-app/src/app/matrix/technique-cell/contextmenu/contextmenu.component.html @@ -31,10 +31,10 @@
-