Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions nav-app/src/app/classes/context-menu-item.spec.ts
Original file line number Diff line number Diff line change
@@ -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
});
});
34 changes: 33 additions & 1 deletion nav-app/src/app/classes/context-menu-item.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@
<div class="contextMenu-button" (click)="viewTechnique()">view technique</div>
<div class="contextMenu-button" (click)="viewTactic()">view tactic</div>
</div>
<div class="contextMenu-section" *ngIf="configService.contextMenuItems.length > 0">
<div class="contextMenu-section" *ngIf="applicableContextMenuItems.length > 0">
<div
class="contextMenu-button"
*ngFor="let contextMenuItem of configService.contextMenuItems"
*ngFor="let contextMenuItem of applicableContextMenuItems"
(click)="openCustomContextMenuItem(contextMenuItem)">
{{ contextMenuItem.label }}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ export class ContextmenuComponent extends CellPopover implements OnInit {
return this.techniqueVM.links;
}

/**
* Custom context menu items configured in assets/config.json, filtered down to
* the ones applicable to the technique/tactic this menu was opened on (see
* ContextMenuItem#appliesTo and the optional limit_techniques config field).
*/
public get applicableContextMenuItems(): ContextMenuItem[] {
return this.configService.contextMenuItems.filter((item) => item.appliesTo(this.technique, this.tactic));
}

constructor(
private element: ElementRef,
public configService: ConfigService,
Expand Down
4 changes: 3 additions & 1 deletion nav-app/src/app/services/config.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,9 @@ export class ConfigService {
console.debug('loaded app configuration settings');

config['custom_context_menu_items'].forEach((item) => {
this.contextMenuItems.push(new ContextMenuItem(item.label, item.url, item.subtechnique_url));
this.contextMenuItems.push(
new ContextMenuItem(item.label, item.url, item.subtechnique_url, item.limit_techniques)
);
});
this.defaultLayers = config['default_layers'];
this.commentColor = config['comment_color'];
Expand Down