Skip to content

Commit 0d10270

Browse files
committed
add FalconFriday repo, category filter chips, 12h cache
1 parent bbb3cc1 commit 0d10270

8 files changed

Lines changed: 280 additions & 15 deletions

File tree

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ Browse, search, pin, and inject KQL queries directly into the Monaco editor.
1313
## Features
1414

1515
- Inline "Threat Hunting Queries" button in the command bar
16-
- Tabs: **User Rules** (bundled), **Reprise99**, **Bert-JanP** (fetched from GitHub)
16+
- Tabs: **User Rules** (bundled), **Reprise99**, **Bert-JanP**, **FalconFriday** (fetched from GitHub)
17+
- Category filter chips for quick sub-filtering within each repo tab
1718
- Search across query name, description, category, and KQL content
1819
- Pin queries for quick access (horizontal pill bar above results)
1920
- Click any query row to inject it into the editor
@@ -32,8 +33,9 @@ Browse, search, pin, and inject KQL queries directly into the Monaco editor.
3233
|------|---------|--------|
3334
| [reprise99/Sentinel-Queries](https://github.com/reprise99/Sentinel-Queries) | ~460 | `.kql` files |
3435
| [Bert-JanP/Hunting-Queries-Detection-Rules](https://github.com/Bert-JanP/Hunting-Queries-Detection-Rules) | ~445 | `.md` with fenced KQL |
36+
| [FalconForceTeam/FalconFriday](https://github.com/FalconForceTeam/FalconFriday) | ~40 | `.md` with fenced KQL |
3537

36-
Rules are fetched lazily on first tab click, cached locally for 1 hour.
38+
Rules are fetched lazily on first tab click, cached locally for 12 hours.
3739

3840
## Build
3941

src/config.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,13 @@ export const STORAGE_KEYS = {
1111
USER_RULES: 'shq_user_rules',
1212
} as const;
1313

14-
// ── Cache TTL (1 hour in ms) ──
15-
export const CACHE_TTL_MS = 60 * 60 * 1000;
14+
// ── Cache TTL (12 hours in ms) ──
15+
export const CACHE_TTL_MS = 12 * 60 * 60 * 1000;
1616

1717
// ── GitHub Repo Configs ──
1818

1919
export interface RepoConfig {
20-
readonly id: 'reprise99' | 'bertjanp';
20+
readonly id: 'reprise99' | 'bertjanp' | 'falconfriday';
2121
readonly owner: string;
2222
readonly repo: string;
2323
readonly branch: string;
@@ -26,7 +26,7 @@ export interface RepoConfig {
2626
/** File extension to match */
2727
readonly extension: '.kql' | '.md';
2828
/** Parser identifier */
29-
readonly parser: 'kql-file' | 'markdown-sentinel';
29+
readonly parser: 'kql-file' | 'markdown-sentinel' | 'markdown-falconfriday';
3030
/** Human-readable label shown in the tab */
3131
readonly label: string;
3232
}
@@ -52,6 +52,16 @@ export const REPOS: readonly RepoConfig[] = [
5252
parser: 'markdown-sentinel',
5353
label: 'Bert-JanP',
5454
},
55+
{
56+
id: 'falconfriday',
57+
owner: 'FalconForceTeam',
58+
repo: 'FalconFriday',
59+
branch: 'main',
60+
basePath: '',
61+
extension: '.md',
62+
parser: 'markdown-falconfriday',
63+
label: 'FalconFriday',
64+
},
5565
] as const;
5666

5767
/** Max concurrent file fetches to avoid flooding */
@@ -65,6 +75,7 @@ export const TABS: readonly TabDefinition[] = [
6575
{ id: 'user', label: 'User Rules', sourceId: 'user' },
6676
{ id: 'reprise99', label: 'Reprise99', sourceId: 'reprise99' },
6777
{ id: 'bertjanp', label: 'Bert-JanP', sourceId: 'bertjanp' },
78+
{ id: 'falconfriday', label: 'FalconFriday', sourceId: 'falconfriday' },
6879
{ id: 'pinned', label: 'Pinned' },
6980
] as const;
7081

src/core/public-repos.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,8 @@ async function fetchAndParseFile(
161161
return parseKqlFile(content, entry.path);
162162
case 'markdown-sentinel':
163163
return parseMarkdownSentinel(content, entry.path);
164+
case 'markdown-falconfriday':
165+
return parseMarkdownFalconFriday(content, entry.path);
164166
default:
165167
return null;
166168
}
@@ -249,6 +251,56 @@ function parseMarkdownSentinel(content: string, filePath: string): HuntingRule[]
249251
return rules.length > 0 ? rules : null;
250252
}
251253

254+
/**
255+
* Parse a .md file from FalconForceTeam/FalconFriday.
256+
* Structure: # Title, ## Detection description, ## Detection section with
257+
* fenced code blocks using ```C# language tag containing KQL queries.
258+
* Category is extracted from the filename suffix (e.g. -Win, -Azure, -AWS).
259+
*/
260+
function parseMarkdownFalconFriday(content: string, filePath: string): HuntingRule | null {
261+
const titleMatch = content.match(/^#\s+(.+)$/m);
262+
const title = titleMatch?.[1]?.trim() ?? '';
263+
if (!title) return null;
264+
265+
const descMatch = content.match(/##\s+Detection\s+description\s*\n([\s\S]*?)(?=\n##\s)/i);
266+
const description = descMatch?.[1]?.trim() ?? '';
267+
268+
// Extract category from filename suffix pattern like -Win, -Azure, -AWS
269+
const fileName = filePath.split('/').pop() ?? '';
270+
const categoryMatch = fileName.match(/-([A-Za-z]+)\.md$/);
271+
const category = categoryMatch?.[1]
272+
? humanizeName(categoryMatch[1])
273+
: 'General';
274+
275+
// FalconFriday uses ```C# for KQL blocks
276+
const codeBlocks = extractAllFencedCodeBlocks(content);
277+
if (codeBlocks.length === 0) return null;
278+
279+
const query = codeBlocks[0].trim();
280+
if (!query) return null;
281+
282+
return {
283+
name: title,
284+
query,
285+
description: description || `FalconFriday detection rule`,
286+
category,
287+
};
288+
}
289+
290+
/**
291+
* Extract all fenced code blocks regardless of language tag.
292+
* Used by the FalconFriday parser since it uses ```C# for KQL.
293+
*/
294+
function extractAllFencedCodeBlocks(text: string): string[] {
295+
const blocks: string[] = [];
296+
const regex = /```[^\n]*\n([\s\S]*?)```/g;
297+
let match: RegExpExecArray | null;
298+
while ((match = regex.exec(text)) !== null) {
299+
if (match[1]) blocks.push(match[1]);
300+
}
301+
return blocks;
302+
}
303+
252304
function extractFencedCodeBlocks(text: string): string[] {
253305
const blocks: string[] = [];
254306
const regex = /```(?:kql|kusto|KQL|Kusto)?\s*\n([\s\S]*?)```/g;

src/core/query-manager.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,22 @@ export function getPinnedRules(): readonly (HuntingRule & { sourceId: RuleSource
105105
return result;
106106
}
107107

108+
export function getCategoriesForSource(id: RuleSourceId): readonly string[] {
109+
const rules = sources.get(id)?.rules ?? [];
110+
const unique = new Set<string>();
111+
for (const rule of rules) {
112+
if (rule.category) unique.add(rule.category);
113+
}
114+
return [...unique].sort((a, b) => a.localeCompare(b));
115+
}
116+
117+
export function filterByCategory(
118+
rules: readonly HuntingRule[],
119+
category: string,
120+
): readonly HuntingRule[] {
121+
return rules.filter((r) => r.category === category);
122+
}
123+
108124
export function searchRules(
109125
rules: readonly HuntingRule[],
110126
query: string,

src/styles/popup.css.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,56 @@ export const popupStyles = `
152152
border-bottom-color: var(--colorTextBrand, #0078d4);
153153
}
154154
155+
/* ── Category Filter ── */
156+
157+
.${CSS_PREFIX}-category-filter {
158+
display: flex;
159+
gap: 4px;
160+
padding: 6px 14px;
161+
border-bottom: 1px solid var(--colorDividerPrimary, #f3f2f1);
162+
flex-shrink: 0;
163+
overflow-x: auto;
164+
overflow-y: hidden;
165+
scrollbar-width: none;
166+
}
167+
168+
.${CSS_PREFIX}-category-filter::-webkit-scrollbar {
169+
display: none;
170+
}
171+
172+
.${CSS_PREFIX}-category-chip {
173+
padding: 2px 8px;
174+
font-size: 11px;
175+
font-weight: 500;
176+
color: var(--colorTextSecondary, #646464);
177+
background: var(--colorContainerBackgroundSecondary, #f3f2f1);
178+
border: 1px solid var(--colorContainerBorderPrimary, #e1dfdd);
179+
border-radius: 10px;
180+
cursor: pointer;
181+
transition: color 0.12s, background 0.12s, border-color 0.12s;
182+
font-family: inherit;
183+
white-space: nowrap;
184+
flex-shrink: 0;
185+
}
186+
187+
.${CSS_PREFIX}-category-chip:hover {
188+
color: var(--colorTextPrimary, #292827);
189+
background: var(--colorControlBackgroundHover, #e8e8e8);
190+
border-color: var(--colorControlBorderSecondary, #d6d6d6);
191+
}
192+
193+
.${CSS_PREFIX}-category-chip--active {
194+
color: var(--colorButtonForegroundPrimary, #fff);
195+
background: var(--colorButtonBackgroundPrimary, #0078d4);
196+
border-color: var(--colorButtonBackgroundPrimary, #0078d4);
197+
}
198+
199+
.${CSS_PREFIX}-category-chip--active:hover {
200+
color: var(--colorButtonForegroundPrimary, #fff);
201+
background: var(--colorButtonBackgroundHover, #106ebe);
202+
border-color: var(--colorButtonBackgroundHover, #106ebe);
203+
}
204+
155205
/* ── Query List ── */
156206
157207
.${CSS_PREFIX}-query-list {

src/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export interface HuntingRule {
99

1010
// ── Rule Sources ──
1111

12-
export type RuleSourceId = 'user' | 'reprise99' | 'bertjanp';
12+
export type RuleSourceId = 'user' | 'reprise99' | 'bertjanp' | 'falconfriday';
1313

1414
export interface RuleSource {
1515
readonly id: RuleSourceId;
@@ -88,7 +88,7 @@ export interface CachedData<T> {
8888

8989
// ── Tab System ──
9090

91-
export type TabId = 'user' | 'reprise99' | 'bertjanp' | 'pinned';
91+
export type TabId = 'user' | 'reprise99' | 'bertjanp' | 'falconfriday' | 'pinned';
9292

9393
export interface TabDefinition {
9494
readonly id: TabId;

src/ui/category-filter.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { CSS_PREFIX } from '../config.ts';
2+
3+
export interface CategoryFilterComponent {
4+
readonly element: HTMLDivElement;
5+
/** Update the list of available categories. Resets selection. */
6+
setCategories(categories: readonly string[]): void;
7+
/** Get the currently selected category, or null if "All" is selected. */
8+
getSelected(): string | null;
9+
/** Reset selection to "All" without triggering onChange. */
10+
reset(): void;
11+
}
12+
13+
export function createCategoryFilter(
14+
onChange: (category: string | null) => void,
15+
): CategoryFilterComponent {
16+
const wrap = document.createElement('div');
17+
wrap.className = `${CSS_PREFIX}-category-filter`;
18+
19+
let selectedCategory: string | null = null;
20+
let currentCategories: readonly string[] = [];
21+
22+
function setCategories(categories: readonly string[]): void {
23+
// Avoid re-rendering if categories haven't changed
24+
if (
25+
categories.length === currentCategories.length &&
26+
categories.every((c, i) => c === currentCategories[i])
27+
) {
28+
return;
29+
}
30+
currentCategories = categories;
31+
// Preserve selection if the selected category still exists in the new list
32+
if (selectedCategory && !categories.includes(selectedCategory)) {
33+
selectedCategory = null;
34+
}
35+
renderChips();
36+
}
37+
38+
function renderChips(): void {
39+
wrap.innerHTML = '';
40+
41+
// Hide entirely when there are 0 or 1 categories (no filtering useful)
42+
if (currentCategories.length <= 1) {
43+
wrap.style.display = 'none';
44+
return;
45+
}
46+
wrap.style.display = '';
47+
48+
// "All" chip
49+
const allChip = createChip('All', selectedCategory === null);
50+
allChip.addEventListener('click', () => {
51+
if (selectedCategory === null) return;
52+
selectedCategory = null;
53+
updateActiveState();
54+
onChange(null);
55+
});
56+
wrap.appendChild(allChip);
57+
58+
// Category chips
59+
for (const cat of currentCategories) {
60+
const chip = createChip(cat, selectedCategory === cat);
61+
chip.addEventListener('click', () => {
62+
if (selectedCategory === cat) {
63+
// Toggle off -> back to "All"
64+
selectedCategory = null;
65+
} else {
66+
selectedCategory = cat;
67+
}
68+
updateActiveState();
69+
onChange(selectedCategory);
70+
});
71+
wrap.appendChild(chip);
72+
}
73+
}
74+
75+
function createChip(label: string, active: boolean): HTMLButtonElement {
76+
const btn = document.createElement('button');
77+
btn.type = 'button';
78+
btn.className = `${CSS_PREFIX}-category-chip`;
79+
if (active) btn.classList.add(`${CSS_PREFIX}-category-chip--active`);
80+
btn.textContent = label;
81+
return btn;
82+
}
83+
84+
function updateActiveState(): void {
85+
const chips = wrap.querySelectorAll<HTMLButtonElement>(`.${CSS_PREFIX}-category-chip`);
86+
let idx = 0;
87+
for (const chip of chips) {
88+
if (idx === 0) {
89+
// "All" chip
90+
chip.classList.toggle(`${CSS_PREFIX}-category-chip--active`, selectedCategory === null);
91+
} else {
92+
const cat = currentCategories[idx - 1];
93+
chip.classList.toggle(`${CSS_PREFIX}-category-chip--active`, selectedCategory === cat);
94+
}
95+
idx++;
96+
}
97+
}
98+
99+
function reset(): void {
100+
selectedCategory = null;
101+
updateActiveState();
102+
}
103+
104+
function getSelected(): string | null {
105+
return selectedCategory;
106+
}
107+
108+
return { element: wrap, setCategories, getSelected, reset };
109+
}

0 commit comments

Comments
 (0)