Rulezet
Community-driven cybersecurity detection rules platform. Centralise, share, validate and manage YARA, Sigma, Suricata, Zeek, CRS, Wazuh, Elastic, NSE and Nova rules at scale.
Introduction
Rulezet is a full-stack web application built with Flask (Python) on the backend and Vue.js 3 on the frontend. It lets security analysts import detection rules from GitHub repositories or create them manually, validate them, tag them, bundle them, comment on them, vote on them, and propose edits — all with a gamification layer that rewards contribution.
app.py— CLI runner, DB managementlaunch.sh— dev/test/init helperapp/__init__.py—create_app()factoryconfig.py— environment configs
Architecture
Application Factory
The app uses the Flask application factory pattern. create_app() in app/__init__.py wires up every extension and blueprint, then immediately calls start_worker(app) to boot the background job thread.
def create_app(config_name=None) → Flask:
# Initialises: db, csrf, migrate, login_manager, sess, mail
# Registers: all blueprints, api_blueprint (CSRF-exempt)
# Calls: start_worker(app)
Extension Initialization Order
| Extension | Object | Purpose |
|---|---|---|
Flask-SQLAlchemy | db | ORM, session management |
Flask-Migrate | migrate | Alembic-based schema migrations |
Flask-Login | login_manager | Session auth, current_user |
Flask-WTF / CSRFProtect | csrf | CSRF tokens (exempted for API) |
Flask-Session | sess | Server-side sessions (SQLAlchemy or filesystem) |
Flask-Mail | mail | Email notifications |
Flask-RESTX | api | REST API + Swagger UI at /api/ |
Directory Layout
rulezet-core/
├── app/
│ ├── __init__.py # create_app() factory
│ ├── core/
│ │ ├── db_class/db.py # ALL SQLAlchemy models (1600+ lines)
│ │ └── utils/ # decorators.py, utils.py
│ ├── api/ # Flask-RESTX namespaces
│ │ ├── api.py # Api object, namespace registration
│ │ ├── rule/ # rule_public_api.py, rule_private_api.py
│ │ ├── bundle/ # bundle_public_api.py, bundle_private_api.py
│ │ └── account/ # account_public_api.py, account_private_api.py
│ ├── features/
│ │ ├── rule/
│ │ │ ├── rule.py # Flask Blueprint views
│ │ │ ├── rule_core.py # Business logic (add/update/delete rules)
│ │ │ ├── rule_format/
│ │ │ │ ├── abstract_rule_type/rule_type_abstract.py # RuleType ABC
│ │ │ │ ├── available_format/ # yara_format.py, sigma_format.py, …
│ │ │ │ ├── main_format.py # format orchestration helpers
│ │ │ │ └── utils_format/
│ │ │ ├── rule_from_github/
│ │ │ │ ├── import_rule/session_class.py # Session_class
│ │ │ │ └── update_rule/update_class.py # Update_class
│ │ │ └── utils/similar_rules/similarity_class.py
│ │ ├── jobs/
│ │ │ ├── job_worker.py # register_handler, start_worker
│ │ │ ├── jobs_core.py # CRUD + lifecycle helpers
│ │ │ └── job_handlers.py # concrete @register_handler functions
│ │ ├── bundle/
│ │ ├── tags/
│ │ ├── account/
│ │ └── home/
│ ├── modules/ # pluggable front-end modules (rulezet-cast…)
│ ├── static/css/core.css # Design system & CSS variables
│ └── templates/ # Jinja2 + Vue.js templates
├── config.py # DevelopmentConfig / TestingConfig / ProductionConfig
├── app.py # CLI entry point
├── launch.sh # Helper script
└── tests/ # pytest suite
Request Lifecycle
Browser / API Client
│
▼
Flask Router
│
├─► Web Blueprint (login_required via @login_required)
│ Jinja2 Template ◄── Vue.js 3 ([[ ]] delimiters)
│
└─► /api/... Blueprint (CSRF-exempt)
├─ Public Namespaces → no auth needed
└─ Private Namespaces → X-API-KEY header → verif_api_key() → @api_required
Configuration
Configuration class is selected via the FLASKENV environment variable. All three classes inherit from a common Config base.
| FLASKENV value | Class | Database | Session | Notes |
|---|---|---|---|---|
development (default) | DevelopmentConfig | postgresql:///rulezet | SQLAlchemy | DEBUG=True |
testing | TestingConfig | sqlite:///rulezet-test.sqlite | filesystem | CSRF disabled, TESTING=True |
production | ProductionConfig | postgresql:///rulezet | SQLAlchemy | DEBUG=False |
Key Config Keys
- SQLALCHEMY_DATABASE_URIstrDatabase connection string
- SECRET_KEYstrFlask session signing key
- SESSION_TYPEstr"sqlalchemy" (dev/prod) or "filesystem" (test)
- WTF_CSRF_ENABLEDboolFalse in testing only
- MAIL_SERVER / MAIL_PORTstr/intEmail server configuration
- MAX_CONTENT_LENGTHintMax upload size
Commands & Scripts
launch.sh
./launch.sh -l # Run dev server (FLASKENV=development)
./launch.sh -t # Run test suite (FLASKENV=testing pytest tests)
./launch.sh -i # Init database
./launch.sh -r # Recreate (drop + init) database
./launch.sh -d # Delete database
app.py CLI flags
python3 app.py # Start the server
python3 app.py -i # Create all tables (db.create_all)
python3 app.py -r # Drop all + recreate
python3 app.py -d # Drop all tables
Flask-Migrate
flask db init # Initialise migrations folder (once)
flask db migrate -m "msg" # Generate migration from model changes
flask db upgrade # Apply pending migrations
Run a single test
FLASKENV=testing pytest tests/test_rule_api.py::test_search_rules -v
Data Models
All models live in a single file: app/core/db_class/db.py (~1600 lines). All use db.Model from Flask-SQLAlchemy.
before_flush SQLAlchemy event listener (receive_before_flush) auto-updates Gamification.score and level whenever a relevant model is inserted or deleted.User & Auth
- idInteger PKAuto-increment primary key
- usernameString(50)Unique display name
- emailString(120)Unique, used for login
- passwordStringHashed (Werkzeug)
- api_keyString(60)REST API key —
generate_api_key() - is_adminBooleanAdmin flag
- verifiedBooleanEmail verification
- created_atDateTimeRegistration timestamp
Rule & Format
- idInteger PK
- uuidString(36)Stable public identifier
- nameString
- contentTextRaw rule text
- format_idFK → FormatRule
- author_idFK → User
- github_pathStringSource file path in repo
- github_urlStringSource repository URL
- descriptionText
- severityString
- scoreIntegerVote aggregate
- created_atDateTime
- updated_atDateTime
- idInteger PK
- nameStringFormat identifier e.g. "yara"
- descriptionText
InvalidRuleModel
- id / uuidInteger / String
- contentTextRaw invalid rule text
- rule_typeString
- errorTextValidation error message(s)
- user_idFK → User
Social & Moderation
| Model | Key Fields | Purpose |
|---|---|---|
RuleVote | user_id, rule_id, value (+1/-1) | Upvote/downvote a rule |
RuleFavoriteUser | user_id, rule_id | Bookmark a rule |
Comment | user_id, rule_id, content, parent_id | Threaded comments on rules |
RepportRule | user_id, rule_id, reason | Flag a rule for review |
RequestOwnerRule | user_id, rule_id, status | Claim authorship of a rule |
RuleEditProposal | rule_id, proposed_content, status | Community edit suggestion |
RuleEditComment | proposal_id, user_id, content | Comments on edit proposals |
RuleEditContribution | proposal_id, user_id | Track contributors of an edit |
RuleUpdateHistory | rule_id, old_content, new_content, updated_at | Diff history for a rule |
NewRule | user_id, format, content | Pending rule submission queue |
RuleStatus | rule_id, status, reason | Review/approval status |
Tags
- idInteger PK
- nameStringUnique tag label
- colorStringHex colour for badge
- created_byFK → User
- rule_idFK → Rule
- tag_idFK → Tag
Bundles
| Model | Key Fields | Purpose |
|---|---|---|
Bundle | id, uuid, name, description, user_id, is_public, score | Named collection of rules |
BundleNode | bundle_id, rule_id, position | Ordered rule within a bundle |
BundleRuleAssociation | bundle_id, rule_id | Many-to-many bridge |
BundleTagAssociation | bundle_id, tag_id | Tags on a bundle |
BundleVote | user_id, bundle_id, value | Vote on a bundle |
CommentBundle | user_id, bundle_id, content | Comments on a bundle |
BundleReactionComment | comment_id, user_id, reaction | Emoji reactions on bundle comments |
GitHub & Import
| Model | Key Fields | Purpose |
|---|---|---|
ImporterResult | uuid, info (JSON), bad_rules, imported, skipped, total, count_per_format (JSON), query_date, user_id | Persisted result of a GitHub import session |
UpdateResult | uuid, info (JSON), updated, skipped, errors, query_date, user_id | Persisted result of a GitHub update session |
Background Jobs
| Model | Key Fields | Purpose |
|---|---|---|
BackgroundJob | uuid, job_type, status (pending/running/done/failed/cancelled/paused), payload (JSON), label, created_by (FK→User), created_at, started_at, finished_at | Persistent job record |
BackgroundJobLog | job_id (FK→BackgroundJob), message, level, created_at | Log entries for a job |
Similarity
| Model | Key Fields | Purpose |
|---|---|---|
RuleSimilarity | rule_id_a, rule_id_b, score (float), method | Stores pre-computed similarity pairs |
SimilarResult | session_uuid, rule_id, similar_rule_id, score | Per-session similarity results |
Gamification
- user_idFK → UserOne-to-one with User
- scoreIntegerCumulative points, auto-updated
- levelIntegerDerived from score thresholds
- rules_ownedInteger
- suggestions_acceptedInteger
- rules_liked_or_dislikedInteger
Gamification
Points are automatically calculated by a before_flush SQLAlchemy event. The POINTS dict and LEVEL_THRESHOLDS dict live in app/core/db_class/db.py.
Point Values
Level Thresholds (LEVEL_THRESHOLDS)
| Level | Min Score |
|---|---|
| 1 | 0 |
| 2 | 500 |
| 3 | 15 000 |
Event Listener
event.listen(db.session, 'before_flush', receive_before_flush)
# receive_before_flush scans session.new / session.deleted
# for Rule, RuleVote, RuleEditContribution instances
# and adjusts the related Gamification row accordingly
Rule Format System
All format support is built around an abstract base class RuleType in app/features/rule/rule_format/abstract_rule_type/rule_type_abstract.py. Each format is a subclass discovered automatically via pkgutil.iter_modules.
ValidationResult Dataclass
- okboolTrue if the rule passed validation
- errorsList[str]Validation error messages
- warningsList[str]Non-fatal warnings
- normalized_contentOptional[str]Cleaned/normalized rule text after validation
RuleType Abstract Methods
| Method / Property | Signature | Purpose |
|---|---|---|
format | @property → str | Format identifier (e.g. "yara") |
get_class() | → str | Human-readable class name |
validate(content) | str → ValidationResult | Parse + validate raw rule text |
parse_metadata(content, info, validation_result) | str, dict, ValidationResult → dict | Extract structured metadata dict from rule |
get_rule_files(file) | str → bool | Whether a filename belongs to this format |
extract_rules_from_file(filepath) | str → List[str] | Split a file into individual rule strings |
find_rule_in_repo(repo_dir, rule_id) | str, str → Optional[str] | Locate a specific rule by ID in a cloned repo |
Auto-Discovery
def load_all_rule_formats() → None:
# Uses pkgutil.iter_modules on available_format/ package
# Imports each module so RuleType.__subclasses__() is populated
app/features/rule/rule_format/available_format/ that subclasses RuleType and implements all abstract methods. It will be discovered automatically.Supported Formats
YARA — yara_format.py
Full YaraRule(RuleType) implementation with advanced retry logic.
- Validation: Calls
yara.compile(). Onundefined identifiererrors, automatically prependsimport "module"and retries up to 10 times. - Extraction: Custom brace-tracking parser that handles strings sections, regex literals, line comments, block comments.
- File extensions:
.yar,.yara
Other Formats
| Format | Class | Library | Extensions |
|---|---|---|---|
| Sigma | SigmaRule | pySigma | .yml, .yaml |
| Suricata | SuricataRule | suricataparser, idstools | .rules |
| Zeek | ZeekRule | zeekscript | .zeek |
| CRS | CRSRule | msc_pyparser | .conf |
| Nova | NovaRule | nova-hunting | .nova |
| NSE | NSERule | Lua parser | .nse |
| Wazuh | WazuhRule | XML/YAML | .xml, .yml |
| Elastic | ElasticRule | JSON/TOML | .toml, .json |
Rule Core Logic
Business logic lives in two files: app/features/rule/rule_core.py (primary CRUD) and app/features/rule/rule_format/main_format.py (format orchestration).
rule_core.py — Key Functions
# Inserts Rule into DB; checks duplicates, resolves FormatRule, assigns tags
# Updates rule, creates RuleUpdateHistory entry
# value must be +1 or -1; creates/updates/removes RuleVote
main_format.py — Format Orchestration
format_files, format_rule, info, format_name, user
) → tuple[int, int, int] # (bad_rules, imported, skipped)
# Async; walks repo_dir, dispatches to each RuleType subclass
rule_content: str, user: User, format_name: str,
url_repo: str, github_path: str
) → tuple[bool, str, Rule]
# Re-validates a previously invalid rule and imports it on success
Default Tag Auto-attachment
_DEFAULT_TAG_NAMES = ['tlp:clear', 'pap:clear']
def _attach_default_tags(rule: Rule, user_id: int) → None:
# Looks up each tag by name (ilike). Skips silently if tag not in DB.
# Idempotent — checks existing associations before inserting.
tlp:clear and pap:clear must be created via the Tags admin UI first. The function covers all creation paths: manual form, parse tab, GitHub import, ZIP import, bad-rule re-import, and the private API.Similarity Engine
Implemented in app/features/rule/utils/similar_rules/similarity_class.py. Combines TF-IDF vectorisation, FAISS ANN search, and RapidFuzz fuzzy scoring.
Similarity_class
user: User,
info: dict,
mode: str = "global", # "global" | "targeted"
target_rule_id: Optional[int] = None,
params: Optional[dict] = None
)
| Step | Tool | Detail |
|---|---|---|
| 1. Vectorise | sklearn TfidfVectorizer | Converts rule content to TF-IDF matrix |
| 2. ANN search | faiss-cpu IndexFlatIP | Fast approximate nearest-neighbour lookup |
| 3. Fuzzy score | rapidfuzz.fuzz.ratio | Character-level ratio, returns 0.0–1.0 |
| 4. Persist | SQLAlchemy | Results stored in RuleSimilarity |
# Runs in thread pool; applies fuzz.ratio to each rule pair in batch
GitHub Import & Update
Import Pipeline — Session_class
Defined in app/features/rule/rule_from_github/import_rule/session_class.py.
# uuid, thread_count=4, jobs Queue, repo_dir, counts (bad/imported/skipped)
| Method | Description |
|---|---|
start() | Walks repo_dir with os.walk, skips hidden dirs/files, matches files to RuleType subclasses, fills the jobs Queue, spawns 4 daemon Threads running process() |
process(loc_app, user) | Worker loop. Dequeues (index, file, filepath, rule_instance). Calls rule_instance.extract_rules_from_file(), validate(), parse_metadata(), then RuleModel.add_rule_core() or BadRuleModel.save_invalid_rule(). |
status() | Returns JSON: {id, total, complete, remaining, stopped, bad_rules, imported, skipped} |
stop() | Clears queue, joins threads (3.5 s timeout each), calls save_info(), removes session from global sessions list, deletes cloned repo folder |
save_info() | Persists ImporterResult to DB with per-format counts |
info dict is enriched per-file: enriched_info = {**self.info, "github_path": filepath} before calling parse_metadata.URL normalisation — .git suffix
GitHub clone URLs often end with .git (e.g. from activity logs), but the source column in the DB stores the html_url from the GitHub API which does not include .git. To avoid empty search results, all three code paths strip the suffix before querying:
| Location | Applied in |
|---|---|
GET /rule/github_detail route | rule.py — strips before passing url to template |
get_rules_page_filter() → sourceFilter | rule_core.py — strips then applies OR (ilike %url%) OR (ilike %url.git%) |
get_all_rule_by_url_github_page() | rule_core.py — same normalise + OR pattern |
Update Pipeline — Update_class
Defined in app/features/rule/rule_from_github/update_rule/update_class.py.
repo_sources: list,
user: User,
info: dict,
mode: str # "by_url" | "by_rule"
)
| Mode | Behaviour |
|---|---|
by_url | Re-clones the source repo and re-processes all rules |
by_rule | Finds each individual rule in the repo using find_rule_in_repo() and updates only the changed ones |
Update_class uses thread_count=1 with a threading.Lock to prevent concurrent DB writes during updates.Background Job System
The job system provides persistent, pause-able, cancellable background tasks via a single worker thread started at app startup.
Job Lifecycle
pending → running → done
↓ ↑
paused → resumed
↓
cancelled
↓
failed (exception in handler)
Worker — job_worker.py
# Decorator — registers a callable into _HANDLERS dict
# Usage: @register_handler('bulk_add_tag_to_rules')
# Called by create_app(). Starts daemon thread → _worker_loop(app)
# On start: recovers any job stuck in "running" state (set to "pending")
# Main loop: polls DB every 2 s for pending jobs
# Dispatches to _HANDLERS[job.job_type](job, app)
jobs_core.py — Lifecycle Functions
| Function | Signature |
|---|---|
create_job | (job_type, payload, label, created_by) → BackgroundJob |
get_job_by_uuid | (uuid) → BackgroundJob | None |
get_jobs_for_user | (user) → List[BackgroundJob] |
cancel_job | (uuid, user) → (bool, str) |
pause_job | (uuid, user) → (bool, str) |
resume_job | (uuid, user) → (bool, str) |
delete_job | (uuid, user) → (bool, str) |
get_zombie_jobs | () → List[BackgroundJob] — running >2 h or pending >6 h |
kill_all_zombies | () → int — count killed |
Registered Handlers — job_handlers.py
| Job Type | Handler Function | Description |
|---|---|---|
bulk_add_tag_to_rules | handle_bulk_add_tag_to_rules | Adds a tag to all rules matching a filter set |
bulk_remove_tag_from_rules | handle_bulk_remove_tag_from_rules | Removes a tag from filtered rules |
delete_github_rules | handle_delete_github_rules | Deletes all rules originating from a given GitHub source |
delete_activity_logs | handle_delete_activity_logs | Bulk-deletes ActivityLog entries by ID list or full wipe with optional action filter |
_build_rule_query(payload) mirrors the exact filter params from the UI search.Activity Logs
Every significant user action is recorded in the ActivityLog table. The system is designed to never raise — all failures are silently swallowed.
Logging a new entry
from app.core.utils.activity_log import log_activity
log_activity(
"rule.create",
f"Created rule '{rule.title}'",
target_type="rule",
target_id=rule.id,
target_uuid=rule.uuid,
is_public=True, # visible in the public feed
extra={"source": "github"} # any JSON-serialisable dict
)
ActivityLog model fields
- actionStringDot-namespaced key — e.g.
rule.create,user.login,admin.delete_user - descriptionTextHuman-readable summary displayed in the UI
- user_idFK → UserAuthor of the action (nullable for system actions)
- target_typeString(32)
rule·bundle·user·tag·job·comment·bundle_comment·github_import·github_update - target_idIntegerDB primary key of the target object
- target_uuidStringStable UUID for redirect links
- is_publicBooleanWhether the entry appears in the public activity feed
- iconStringFontAwesome class auto-derived from action (overridable)
- ip_addressStringRequester IP (blurred in UI until revealed)
- extraJSONArbitrary context dict
- created_atDateTime
Admin UI — /admin/logs
| Feature | Detail |
|---|---|
| Visibility badge | Clickable Public / Private pill in the table — single click toggles is_public via POST /admin/logs/edit/:id |
| Bulk visibility | Select rows → selection bar → Public or Private button → POST /admin/logs/set_visibility |
| Bulk delete | Select rows → Delete → creates a delete_activity_logs background job |
| Delete ALL | Wipes all logs (with optional action filter) via background job |
| Click row | Opens target resource in a new tab (rule detail, user profile, bundle, job…) |
| Sensitive data | Username and IP are blurred; revealed by clicking the eye toggle |
Admin endpoints
{ description, icon, is_public } (all optional).{ "ids": [1,2,3], "is_public": true }. Returns { "success": true, "updated": N }.{ "ids": [...] } or { "delete_all": true, "action_filter": "rule.create" }. Creates a background job.page, per_page, search, action. Returns paginated log list.UI — Dark Mode System
Dark mode is toggled by adding the dark-mode class to <body>. All colours reference CSS custom properties. The active set is defined in :root (light) and body.dark-mode (dark) blocks in app/static/css/core.css.
Key CSS variables
| Variable | Light | Dark | Use |
|---|---|---|---|
--text-color | #1e1e1e | #e2e8f0 | Primary text, headings |
--subtle-text-color | #6c757d | #94a3b8 | Muted / secondary text |
--card-bg-color | #f9f9f9 | #2d3f55 | Card backgrounds |
--border-color | #dee2e6 | #3d5068 | Borders, dividers |
--light-bg-color | #f1f1f1 | #3d5068 | Table headers, subtle bg |
--bg-color | #f7f7f7 | #1e293b | Page background |
var(--color-text) — this variable does not exist and will silently inherit instead of applying the intended colour. Always use var(--text-color) (primary) or var(--subtle-text-color) (muted).Dark mode overrides (core.css sections 17–18)
| Selector | Fix |
|---|---|
body.dark-mode .text-muted | Uses var(--subtle-text-color) = #94a3b8 — not the undefined --color-text |
body.dark-mode .table-light th/td | Uses var(--light-bg-color) — prevents white thead on dark background |
body.dark-mode .bg-*-subtle | Semi-transparent overrides for Bootstrap subtle colours (success, secondary, danger, warning, primary) |
body.dark-mode .table .opacity-50 | Raised to 0.75 — 50% opacity on dark text is unreadable |
UI — Tag Tooltips
Tag tooltips are rendered by SingleTagDisplay in app/static/js/tags/singleTagDisplay.js. They use Vue 3 <teleport to="body"> with position: fixed computed at mouseenter time.
Why teleport?
The rules carousel's outer wrapper has overflow: hidden (required to clip the sliding track). A standard position: absolute tooltip inside that container is clipped by the overflow boundary regardless of z-index. Teleporting to <body> and using position: fixed bypasses the clipping context entirely.
Positioning logic
function onEnter() {
const rect = wrapperEl.value.getBoundingClientRect();
let left = rect.left + rect.width / 2 - TIP_WIDTH / 2;
left = Math.max(8, Math.min(left, window.innerWidth - TIP_WIDTH - 8));
tooltipStyle.value = {
position: 'fixed',
top: (rect.top - TIP_GAP) + 'px', // above the tag
left: left + 'px',
transform: 'translateY(-100%)', // shift up by own height
zIndex: '9999',
opacity: '0', // start invisible
};
showTooltip.value = true;
Vue.nextTick(() => {
tooltipStyle.value = { ...tooltipStyle.value, opacity: '1' }; // fade in
});
}
| Behaviour | Detail |
|---|---|
| Fade-in | opacity: 0 → 1 via nextTick + transition: opacity 0.2s ease |
| Mouse bridge | 120ms setTimeout on mouseleave — cancelled when mouse enters the tooltip |
| Viewport clamping | Left edge clamped to [8, window.innerWidth - TIP_WIDTH - 8] |
| Scroll detection | Description area uses overflow-y: auto; max-height: 100px — no "scroll for more" label |
API Overview
REST API powered by Flask-RESTX. Swagger UI is served at /api/. All API routes are CSRF-exempt. The base URL for a local dev server is http://127.0.0.1:7009.
| Namespace label | URL prefix | Auth | Source file |
|---|---|---|---|
| Public action on Rule ✅ | /api/rule/public | None | app/api/rule/rule_public_api.py |
| Private action on Rule 🔑 | /api/rule/private | X-API-KEY | app/api/rule/rule_private_api.py |
| Public action on Bundle ✅ | /api/bundle/public | None | app/api/bundle/bundle_public_api.py |
| Private action on Bundle 🔑 | /api/bundle/private | X-API-KEY | app/api/bundle/bundle_private_api.py |
| Public account action ✅ | /api/account/public | None | app/api/account/account_public_api.py |
| Private account action 🔑 | /api/account/private | X-API-KEY | app/api/account/account_private_api.py |
Content-Type: application/json) or as query string parameters for most endpoints. Private endpoints always require the X-API-KEY header.API Authentication
Private endpoints require a 60-character alphanumeric API key passed in the X-API-KEY HTTP request header. Your key is shown in your Rulezet profile page.
Auth utilities — app/core/utils/utils.py
# Cryptographically-random alphanumeric key (secrets.token_hex based)
# Returns True if X-API-KEY header is present and matches a user row
# Resolves X-API-KEY → User object (None if key invalid)
@api_required decorator — app/core/utils/decorators.py
Wraps any private resource method. Returns 401 JSON when the key is missing or invalid.
from app.core.utils.decorators import api_required
class MyPrivateResource(Resource):
@api_required
def get(self):
user = utils.get_user_from_api(request.headers)
...
Error responses
| Status | Condition | Body |
|---|---|---|
401 | Missing or invalid X-API-KEY | {"message": "Unauthorized"} |
403 | Key valid but not enough permissions | {"success": false, "message": "Access denied"} |
Quick test
curl -X GET http://127.0.0.1:7009/api/rule/private/me \
-H "X-API-KEY: YOUR_60_CHAR_KEY"
# 200 → {"message": "Welcome Alice!", "user_id": 4}
# 401 → {"message": "Unauthorized"}
Rule Public API
File: app/api/rule/rule_public_api.py — No authentication required. All endpoints accept GET query parameters.
GET /api/rule/public/searchPage
| Parameter | Type | Default | Allowed values / notes |
|---|---|---|---|
search | string | — | Keyword to match against rule title |
author | string | — | Filter by author username |
rule_type | string | — | yara, sigma, suricata, zeek, crs, nova, nse, wazuh, elastic |
sort_by | string | — | newest · oldest · most_likes · least_likes |
page | integer | 1 | 1-based page number |
per_page | integer | 10 | Items per page |
curl -G "http://127.0.0.1:7009/api/rule/public/searchPage" \
--data-urlencode "search=detect" \
--data-urlencode "author=John" \
--data-urlencode "sort_by=newest" \
--data-urlencode "page=1" \
--data-urlencode "per_page=10"
{
"total_rules_found": 42,
"total_pages": 5,
"pagination": {
"prev_page": null,
"current_page": 1,
"next_page": "http://127.0.0.1:7009/api/rule/public/searchPage?page=2&per_page=10&search=detect"
},
"results": [
{
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"title": "Detect_Ransomware_Generic",
"description": "Detects generic ransomware patterns",
"author": "analyst01",
"creation_date": "2025-03-15T10:23:00",
"format": "yara",
"content": "rule Detect_Ransomware_Generic { ... }"
}
]
}
GET /api/rule/public/search
searchPage but returns all matching rules in a single response (no pagination).| Parameter | Type | Allowed values |
|---|---|---|
search | string | Free text |
author | string | Author username |
rule_type | string | yara · sigma · suricata · zeek · crs · nova · nse · wazuh · elastic |
sort_by | string | newest · oldest · most_likes · least_likes |
curl -G "http://127.0.0.1:7009/api/rule/public/search" \
--data-urlencode "search=detect" \
--data-urlencode "author=malgamy12" \
--data-urlencode "rule_type=yara" \
--data-urlencode "sort_by=newest"
{
"total_rules_found": 3,
"results": [
{ "uuid": "...", "title": "...", "description": "...", "author": "...",
"creation_date": "2025-01-10T08:00:00", "format": "yara", "content": "..." }
]
}
GET /api/rule/public/Convert_MISP
"misp_object": null.| Parameter | Type | Notes |
|---|---|---|
search | string | Keyword in rule title |
author | string | Filter by author |
rule_type | string | Format filter |
sort_by | string | newest · oldest · most_likes · least_likes |
curl -G "http://127.0.0.1:7009/api/rule/public/Convert_MISP" \
--data-urlencode "search=mars" \
--data-urlencode "author=John" \
--data-urlencode "rule_type=yara" \
--data-urlencode "sort_by=newest"
{
"total_rules_found": 1,
"results": [
{
"uuid": "...", "title": "Mars_Ransomware", "format": "yara",
"misp_object": { "name": "yara", "meta-category": "antivirus", ... }
}
]
}
GET /api/rule/public/detail/<rule_id>
rule_id.| Path param | Type | Description |
|---|---|---|
rule_id | integer | The internal DB primary key of the rule |
curl http://127.0.0.1:7009/api/rule/public/detail/6
{
"id": 6,
"title": "Detect_Mimikatz",
"format": "yara",
"version": "1.0",
"to_string": "rule Detect_Mimikatz { ... }",
"description": "Detects Mimikatz credential dumper",
"source": "internal",
"license": "MIT",
"cve_id": [],
"original_uuid": null,
"user": { "id": 2, "first_name": "Alice", "last_name": "Martin" }
}
404 if the rule or author does not exist.GET /api/rule/public/all_by_user/<user_id>
curl http://127.0.0.1:7009/api/rule/public/all_by_user/4
{
"message": "3 rules found for user Alice Martin",
"rules": [
{ "id": 6, "title": "...", "format": "yara", "version": "1.0",
"to_string": "...", "description": "...", "source": "...",
"license": "MIT", "cve_id": null, "original_uuid": null,
"user": { "id": 4, "first_name": "Alice", "last_name": "Martin" } }
],
"success": true
}
GET /api/rule/public/search_rules_by_cve
| Parameter | Type | Description |
|---|---|---|
cve_ids | string | Comma-separated list or raw string with one or more IDs — e.g. CVE-2021-44228,GHSA-j8v8-6h6r-m6pq |
curl -G "http://127.0.0.1:7009/api/rule/public/search_rules_by_cve" \
--data-urlencode "cve_ids=CVE-2021-44228,GHSA-j8v8-6h6r-m6pq"
{
"detected_patterns": ["CVE-2021-44228", "GHSA-j8v8-6h6r-m6pq"],
"total_matches": 5,
"stats": { "yara": 3, "sigma": 2 },
"results": [ { "uuid": "...", "title": "Log4Shell_Detect", ... } ]
}
Rule Private API
File: app/api/rule/rule_private_api.py — All endpoints require the X-API-KEY header decorated with @api_required.
GET /api/rule/private/me
curl http://127.0.0.1:7009/api/rule/private/me \
-H "X-API-KEY: YOUR_KEY"
{ "message": "Welcome Alice!", "user_id": 4 }
POST /api/rule/private/create
RuleType subclass before insertion.Content-Type: application/json) or URL query parameters. The author field is always set from the API key user and cannot be overridden.| Field | Type | Required | Constraints / Notes |
|---|---|---|---|
title | string | Yes | Must be unique and non-empty |
format | string | Yes | yara · sigma · suricata · zeek · crs · nova · nse · wazuh · elastic |
to_string | string | Yes | Full rule text. Must pass verify_syntax_rule_by_format() |
version | string | Yes | Non-empty version string (e.g. "1.0") |
license | string | Yes | Non-empty license string (e.g. "MIT") |
description | string | No | Defaults to "No description provided" |
source | string | No | Defaults to authenticated user's full name |
original_uuid | string | No | Preserve original UUID from source system |
cve_id | string | No | CVE format: CVE-YYYY-NNNNN. Validated with utils.detect_cve() |
curl -X POST http://127.0.0.1:7009/api/rule/private/create \
-H "Content-Type: application/json" \
-H "X-API-KEY: YOUR_KEY" \
-d '{
"title": "Detect_Mimikatz_v2",
"format": "yara",
"version": "2.0",
"license": "MIT",
"to_string": "rule Detect_Mimikatz { strings: $s = \"sekurlsa\" condition: $s }",
"description": "Detects Mimikatz sekurlsa module",
"source": "internal",
"cve_id": "CVE-2021-36934"
}'
{ "message": "Rule created successfully", "rule": { "id": 99, "uuid": "...", "title": "Detect_Mimikatz_v2", ... } }
| Status | Condition |
|---|---|
200 | Rule created successfully |
400 | Missing required fields, invalid CVE format, or syntax validation failure |
409 | A rule with that title already exists |
POST /api/rule/private/delete
| Field (JSON body) | Type | Required | Notes |
|---|---|---|---|
rule_id | integer | Yes | Internal DB primary key of the rule |
curl -X POST http://127.0.0.1:7009/api/rule/private/delete \
-H "Content-Type: application/json" \
-H "X-API-KEY: YOUR_KEY" \
-d '{"rule_id": 42}'
200 → {"success": true, "message": "Rule with ID '42' deleted successfully"}
403 → {"success": false, "message": "Access denied: you are not the owner or an admin"}
404 → {"success": false, "message": "No rule found with ID '42'"}
GET /api/rule/private/favorite/<rule_id>
| Path param | Type | Description |
|---|---|---|
rule_id | integer | ID of the rule to favourite / unfavourite |
curl http://127.0.0.1:7009/api/rule/private/favorite/4 \
-H "X-API-KEY: YOUR_KEY"
200 → {"success": true, "message": "Rule added to favorites"}
200 → {"success": true, "message": "Rule removed from favorites"} # if already favorited
403 → {"success": false, "message": "Unauthorized"}
POST /api/rule/private/import_rules_from_github
user.is_admin). Non-admins receive a 400 error.| Field (JSON body) | Type | Required | Notes |
|---|---|---|---|
url | string | Yes | Valid GitHub repository URL (validated by valider_repo_github()) |
license | string | Yes | License to apply to all imported rules (e.g. "MIT") |
curl -X POST http://127.0.0.1:7009/api/rule/private/import_rules_from_github \
-H "Content-Type: application/json" \
-H "X-API-KEY: ADMIN_KEY" \
-d '{
"url": "https://github.com/org/yara-rules.git",
"license": "MIT"
}'
200 → { "success": true, "imported": 183, "skipped": 12, "failed": 3 }
400 → { "success": false, "message": "You have to be an admin to import" }
400 → { "success": false, "message": "Invalid GitHub URL" }
500 → { "success": false, "message": "An error occurred while importing from: ...", "error": "..." }
POST /api/rule/private/dumpRules
{} to get everything.| Field (JSON body) | Type | Description |
|---|---|---|
format_name | string or list | "yara", ["yara","sigma"], or ["all"] for every format |
created_after | string | ISO date — "YYYY-MM-DD" or "YYYY-MM-DD HH:MM" |
created_before | string | Same format |
updated_after | string | Same format |
updated_before | string | Same format |
top_liked | integer | Return only the top N most liked rules |
top_disliked | integer | Return only the top N most disliked rules |
curl -X POST http://127.0.0.1:7009/api/rule/private/dumpRules \
-H "Content-Type: application/json" \
-H "X-API-KEY: YOUR_KEY" \
-d '{}'
curl -X POST http://127.0.0.1:7009/api/rule/private/dumpRules \
-H "Content-Type: application/json" \
-H "X-API-KEY: YOUR_KEY" \
-d '{
"format_name": ["yara", "sigma"],
"created_after": "2025-01-01 00:00",
"created_before": "2025-12-31 23:59",
"top_liked": 10
}'
{
"success": true,
"message": "Rules dump successfully generated.",
"filters_applied": { "format_name": ["yara","sigma"], "top_liked": 10, ... },
"data": {
"summary_by_format": { "yara": 120, "sigma": 75, "total_rules": 195 },
"rules": [
{
"id": 1, "title": "Example", "format": "yara", "author": "Alice",
"created_at": "2025-03-01 12:00", "updated_at": "2025-04-10 09:30",
"likes": 12, "dislikes": 0,
"to_string": "rule Example { condition: true }",
"cve_id": "CVE-2025-1234"
}
]
}
}
| Status | Condition |
|---|---|
200 | Dump generated |
400 | Invalid JSON body or filter params |
404 | No rules match the filters |
403 | Missing/invalid API key |
POST /api/rule/private/search
| Field | Type | Default | Notes |
|---|---|---|---|
search | string | — | Free text across title, description, format, author, content, uuid |
search_field | string | all | all · title · content |
exact_match | boolean | false | Title uses strict equality; content uses case-sensitive LIKE |
author | string | — | Partial, case-insensitive match |
rule_type | string | — | yara · sigma · suricata · zeek · crs · nova · nse · wazuh · elastic |
source | string | — | Comma-separated for OR: "github.com,internal" |
license | string | — | Comma-separated for OR: "MIT,GPL" |
vulnerabilities | list[string] | — | CVE IDs: ["CVE-2021-44228"] — validated as CVE-YYYY-NNNNN |
tags | list[string] | — | Tag names: ["malware","apt"] |
sort_by | string | newest | newest · oldest · most_likes · least_likes |
page | integer | 1 | Must be ≥ 1 |
per_page | integer | 20 | 1–100 |
paginate | boolean | true | Set to false to get all results in one shot |
fields | list[string] | all | Subset of available fields (see below) |
fields param: id · title · format · author · license · description · version · source · uuid · original_uuid · creation_date · last_modif · vote_up · vote_down · to_string · cve_id · github_pathcurl -X POST http://127.0.0.1:7009/api/rule/private/search \
-H "Content-Type: application/json" \
-H "X-API-KEY: YOUR_KEY" \
-d '{
"search": "mimikatz",
"rule_type":"yara",
"tags": ["credential_access"],
"sort_by": "most_likes",
"page": 1,
"per_page": 20
}'
curl -X POST http://127.0.0.1:7009/api/rule/private/search \
-H "Content-Type: application/json" \
-H "X-API-KEY: YOUR_KEY" \
-d '{
"rule_type": "sigma",
"paginate": false,
"fields": ["uuid","title","to_string","creation_date"]
}'
{
"success": true,
"total": 142,
"page": 1,
"per_page": 20,
"pages": 8,
"paginate": true,
"rules": [
{ "uuid": "...", "title": "Mimikatz_Generic", "format": "yara",
"author": "Alice", "vote_up": 15, "vote_down": 1, ... }
]
}
Bundle API
GET /api/bundle/public/search
File: app/api/bundle/bundle_public_api.py — No authentication required.
| Parameter | Type | Required | Notes |
|---|---|---|---|
search | string | Yes | Keyword to match against bundle name. Empty value returns 400. |
curl -G "http://127.0.0.1:7009/api/bundle/public/search" \
--data-urlencode "search=ransomware"
{
"message": "2 bundle(s) found",
"bundle_list": [
{ "id": 7, "name": "Ransomware Detections", "description": "...", "public": true, "score": 10 }
]
}
POST /api/bundle/private/create
File: app/api/bundle/bundle_private_api.py — Requires X-API-KEY.
| Field (JSON body) | Type | Required | Notes |
|---|---|---|---|
name | string | Yes | Non-empty string |
public | boolean | Yes | Strict boolean — must be true or false (not a string) |
description | string | No | Defaults to empty string |
curl -X POST http://127.0.0.1:7009/api/bundle/private/create \
-H "Content-Type: application/json" \
-H "X-API-KEY: YOUR_KEY" \
-d '{
"name": "My YARA Bundle",
"description": "Collection of YARA rules for ransomware detection",
"public": true
}'
{ "message": "Bundle created successfully", "bundle_id": 12 }
| Status | Condition |
|---|---|
200 | Bundle created |
400 | name missing/invalid, or public is not a boolean |
500 | DB error |
GET /api/bundle/private/add_rule_bundle
| Parameter (query) | Type | Required | Notes |
|---|---|---|---|
rule_id | integer | Yes | ID of the rule to add |
bundle_id | integer | Yes | ID of the target bundle (must exist and be yours) |
description | string | Yes | Comment or note for this rule within the bundle |
curl "http://127.0.0.1:7009/api/bundle/private/add_rule_bundle?rule_id=42&bundle_id=7&description=Important+APT+rule" \
-H "X-API-KEY: YOUR_KEY"
200 → {"success": true, "message": "Rule added!", "toast_class": "success"}
400 → {"success": false, "message": "Missing rule_id or bundle_id or description","toast_class": "danger"}
401 → {"success": false, "message": "You don't have the permission to do that!", "toast_class": "danger"}
404 → {"success": false, "message": "Bundle not found", "toast_class": "danger"}
GET /api/bundle/private/remove_rule_bundle
| Parameter (query) | Type | Required |
|---|---|---|
rule_id | integer | Yes |
bundle_id | integer | Yes |
curl "http://127.0.0.1:7009/api/bundle/private/remove_rule_bundle?rule_id=42&bundle_id=7" \
-H "X-API-KEY: YOUR_KEY"
200 → {"success": true, "message": "Rule removed!"}
401 → {"success": false, "message": "You don't have the permission to do that!"}
404 → {"success": false, "message": "Bundle not found"}
500 → {"success": false, "message": "Rule not found in this bundle or already removed"}
POST /api/bundle/private/edit_bundle/<bundle_id>
| Path param | Type | Description |
|---|---|---|
bundle_id | integer | ID of the bundle to update |
| Field (JSON body) | Type | Notes |
|---|---|---|
name | string | New bundle name (optional) |
description | string | New description (optional) |
curl -X POST http://127.0.0.1:7009/api/bundle/private/edit_bundle/7 \
-H "Content-Type: application/json" \
-H "X-API-KEY: YOUR_KEY" \
-d '{"name": "Updated Bundle Name", "description": "New description"}'
200 → {"success": true, "message": "Bundle updated successfully"}
401 → {"success": false, "message": "You don't have the permission to do that!"}
404 → {"success": false, "message": "Bundle not found"}
Account API
POST /api/account/public/register
File: app/api/account/account_public_api.py — No authentication required.
X-API-KEY on success.| Field (JSON body) | Type | Required | Constraints |
|---|---|---|---|
email | string | Yes | Valid email format, must be unique |
password | string | Yes | 8–64 chars, at least 1 uppercase, 1 lowercase, 1 digit |
first_name | string | Yes | Non-empty |
last_name | string | Yes | Non-empty |
curl -X POST http://127.0.0.1:7009/api/account/public/register \
-H "Content-Type: application/json" \
-d '{
"email": "analyst@example.com",
"password": "Secur3Pass!",
"first_name": "Alice",
"last_name": "Martin"
}'
201 → { "message": "User registered successfully", "X-API-KEY": "abc123...60chars..." }
400 → { "message": "Password must contain at least one digit." }
409 → { "message": "Email already exists" }
POST /api/account/public/login
X-API-KEY instead.| Field | Type | Required | Notes |
|---|---|---|---|
email | string | Yes | Registered email |
password | string | Yes | Account password |
remember_me | boolean | No | Extends session — must be strict boolean |
curl -X POST http://127.0.0.1:7009/api/account/public/login \
-H "Content-Type: application/json" \
-d '{"email": "analyst@example.com", "password": "Secur3Pass!", "remember_me": false}'
200 → { "message": "Logged in successfully" }
400 → { "message": "remember_me must be a boolean" }
401 → { "message": "Invalid email or password" }
POST /api/account/public/logout
@login_required — browser session must be active).{ "message": "You have been logged out." }
POST /api/account/private/edit
File: app/api/account/account_private_api.py — Requires active login session (uses @login_required, not API key).
| Field (JSON body) | Type | Required | Constraints |
|---|---|---|---|
first_name | string | Yes | Non-empty |
last_name | string | Yes | Non-empty |
email | string | Yes | Valid email, unique |
password | string | No | 8–64 chars, uppercase + lowercase + digit + special char (@$!%*?&) |
curl -X POST http://127.0.0.1:7009/api/account/private/edit \
-H "Content-Type: application/json" \
-H "X-API-KEY: YOUR_KEY" \
-d '{
"first_name": "Alice",
"last_name": "Dupont",
"email": "alice@new.com",
"password": "NewP@ssw0rd!"
}'
200 → { "message": "User updated successfully" }
400 → { "message": "Password must contain at least one special character (@$!%*?&)" }
409 → { "message": "Email already registered." }
GET /api/account/private/favorite/get_rules_page_favorite
| Parameter (query) | Type | Default |
|---|---|---|
page | integer | 1 |
{ "rule": [ { "id": 6, "title": "...", ... } ], "total_pages": 3 }
POST /api/account/private/favorite/delete_rule
| Parameter (query) | Type | Required |
|---|---|---|
id | integer | Yes |
curl -X POST "http://127.0.0.1:7009/api/account/private/favorite/delete_rule?id=42" \
-H "X-API-KEY: YOUR_KEY"
200 → {"success": true, "message": "Rule deleted!"}
403 → {"success": false, "message": "Access denied"}
Testing
Tests live in tests/ and use pytest + Flask's test client. The testing config uses SQLite so no PostgreSQL installation is needed.
conftest.py Fixtures
def app() → Flask:
# FLASKENV=testing, fresh SQLite DB, creates test users + rules
def client(app) → FlaskClient:
# app.test_client()
Running Tests
# Full suite
./launch.sh -t
# Single test file
FLASKENV=testing pytest tests/test_rule_api.py -v
# Single test function
FLASKENV=testing pytest tests/test_rule_api.py::test_create_yara_rule -v
# With coverage
FLASKENV=testing pytest tests/ --cov=app --cov-report=html
FLASKENV=development — the test config disables CSRF and uses a throw-away SQLite database. The development PostgreSQL database must not be touched by the test suite.Flask Blueprints
| Blueprint | URL Prefix | File | Responsibility |
|---|---|---|---|
home_bp | / | app/features/home/home.py | Landing page, search, feed |
account_bp | /account | app/features/account/account.py | Register, login, profile, settings, API key |
rule_bp | /rule | app/features/rule/rule.py | Rule CRUD, votes, comments, proposals |
bundle_bp | /bundle | app/features/bundle/bundle.py | Bundle CRUD, export, votes, comments |
tags_bp | /tags | app/features/tags/tags.py | Tag management, bulk operations |
jobs_bp | /jobs | app/features/jobs/jobs.py | Background job management UI |
api_blueprint | /api | app/api/api.py | Flask-RESTX REST API + Swagger |
Frontend — Vue.js 3
[[ ]] as delimiters (not {{ }}) to avoid conflict with Jinja2. Vue apps are ES-module scripts with type="module".