-
-
Notifications
You must be signed in to change notification settings - Fork 0
Development Guide
docker restart iriswebapp_appGunicorn caches templates; auto_reload = False in the dev compose. A hard browser
reload alone is useless — the server keeps sending the cached template.
docker restart iriswebapp_app iriswebapp_worker iriswebapp_ai_workerPython files under source/app/ are volume-mounted — edits are live in the container.
A restart picks them up without a rebuild.
Exception: if you edited docker/webApp/iris-entrypoint.sh or gunicorn settings,
you need a rebuild (the entrypoint is baked into the image).
Static assets are baked into the image at build time, not volume-mounted. Two steps:
# 1. Deploy to running container immediately
docker cp ui/public/assets/<path> iriswebapp_app://iriswebapp/static/<path>
# Always use PowerShell — Bash/MSYS translates paths incorrectly
# 2. Also edit the source file so the fix survives the next rebuildVerify the cp landed before restarting:
docker exec iriswebapp_app grep -c '<something>' /iriswebapp/static/<path>cd ui && npm run build
docker compose -f docker-compose.dev.yml build app worker ai_worker
docker compose -f docker-compose.dev.yml up -d --force-recreate --no-deps app worker ai_workerBundled JS (e.g. case.asset.js, case.rfiles.js) is compiled by Vite → baked into
the image. No shortcut — full rebuild required.
Exception: ui/public/assets/js/iris/case.time.js is a static file (uses Vite
publicDir), so it deploys via docker cp like any other static asset.
docker exec iriswebapp_app alembic -c /iriswebapp/app/alembic.ini upgrade headOr just restart — db.create_all() runs before Alembic and handles new tables. Column
additions on existing tables still need Alembic to run.
nginx conf is baked into the nginx image with envsubst substituting ${VAR} placeholders.
Do not docker cp the raw source file — it contains literal ${VAR} which crashes nginx.
docker compose -f docker-compose.dev.yml build nginx
docker compose -f docker-compose.dev.yml up -d --force-recreate --no-deps nginxdocker compose -f docker-compose.dev.yml build app worker ai_worker
docker compose -f docker-compose.dev.yml up -d --force-recreate --no-deps app worker ai_worker--no-deps keeps the DB and RabbitMQ containers running so no data is lost.
git pull
docker compose -f docker-compose.dev.yml up -d --build --force-recreateAlways include --force-recreate — a plain up --build can leave worker / ai_worker
on the old container while only app is rebuilt, causing NotImplementedError in
task_hook_wrapper before any module runs.
Vite 8 / rolldown 1.1.4 silently drops function declarations that have no callers
within their own module — functions referenced only from HTML onclick handlers
are treated as dead code.
Rule: any JS file with zero ES6 import statements belongs in
ui/public/assets/js/iris/. Vite copies public/ verbatim; nothing is tree-shaken.
JS files that import from $lib or npm packages belong in ui/src/pages/.
Every npm package referenced by a vite-plugin-static-copy target must also be
declared in package.json — npm ci will not install transitive deps, and the
plugin silently skips missing source paths (producing CDN 404s at runtime).
Use vite-plugin-static-copy ^3.3.0 or later for Vite 8 compatibility — 1.x and 2.x
declare peer vite: "^5.0.0 || ^6.0.0" and fail to install against Vite 8.
When a file cannot move to ui/public/, expose its functions on window.
case.summary.js imports crc32, so it must stay in ui/src/pages/ and the
verbatim-copy escape route is unavailable. Rolldown tree-shook its
onclick-only functions and the Generate-report button silently died
(report_template_selector is not defined). The fix is an explicit window
assignment at the end of the file:
Object.assign(window, {
report_template_selector, act_report_template_selector,
gen_report, gen_act_report,
});The assignment is itself an in-module use, so rolldown keeps the functions, and
it makes them the globals the inline handlers resolve against. Note that
rollupOptions.treeshake: false does not save uncalled declarations under
rolldown — do not rely on it.
Jinja processes {% delimiters everywhere in the template file, including inside
<script> tags and /* */ JS comments. Writing {% block javascripts %} in a JS
comment causes TemplateSyntaxError. Write it in plain English.
<script> blocks in {% block content %} run at browser parse time — before
{% block javascripts %} where jQuery loads. Calling $() directly at IIFE top level
throws ReferenceError: $ is not defined. Wrap jQuery wiring in:
document.addEventListener('DOMContentLoaded', function() {
// $ is available here
});Vanilla JS (document.getElementById, fetch, MutationObserver) is safe at IIFE top level.
The JSON-body CSRF rule extends to multipart/form-data. IRIS-NG's CSRF validator reads
request.form only; append csrf_token as a form field in FormData:
const body = new FormData();
body.append('file', file);
body.append('csrf_token', document.getElementById('csrf_token').value);To get a #csrf_token input on a page that has no WTForms form, pass a bare FlaskForm()
to the template and render:
<form style="display:none;">{{ form.hidden_tag() }}</form>{{ csrf_token() }} is not a Jinja global in this app — it renders empty.
jQuery .data('cluster-id') auto-casts numeric-looking strings to integers. Use .attr('data-cluster-id') when the value needs to stay a string for === comparison against server-returned strings.
Never embed user-controlled values inside onclick="fn('...')" string literals.
Any .replace() applied after HTML-escaping misses backslashes and other metacharacters.
Use data-* attributes + a delegated handler instead:
<button data-case-id="{{ case.case_id }}">Click</button>document.addEventListener('click', function(e) {
const btn = e.target.closest('[data-case-id]');
if (!btn) return;
const id = btn.getAttribute('data-case-id'); // .attr(), not .data()
});str.replace('x', 'y') only replaces the first occurrence. Use /regex/g:
phaseName.replace(/&/g, 'and')Mismatches break autofill, a11y, and click-to-focus. Always set for= to the input's
id=, not a descriptive name.
filterXSS sanitizes HTML tag/attribute names — it does not escape " inside attribute
values. Use irisAttrEscape() (defined in suggesttag.js) when interpolating values
into data-val="..." or any HTML attribute.
Defer chart rendering to shown.bs.tab — hidden tab panes have offsetWidth = 0 and
Chart.js draws all elements at (0,0).
In a Bootstrap flip-card back face, defer the render() call 50 ms after showing
the chart slot (CSS transition from position:absolute to normal flow takes one frame).
The begin_transaction() wrapper in source/app/alembic/env.py was commented out
upstream, causing all ALTER TABLE migrations to silently fail. iris-ng restores it.
Watch for accidental reverts on upstream cherry-picks.
db.create_all() runs before Alembic. If a CHECK constraint is only in the migration
file, _has_table guard skips op.create_table on fresh stacks and the constraint
never lands. Always define CHECK + UNIQUE constraints on the ORM model's __table_args__.
input_hash should include only what changes when the data changes, not when the clock
advances. A field like hours_since_last_activity (derived from utcnow()) changes
every call — hash the underlying updated_at timestamps instead.
Workers in a ThreadPoolExecutor do not inherit the calling thread's app context:
def _worker(app, ...):
with app.app_context():
... # ORM queries hereReturn primitive IDs from workers; re-fetch ORM objects in the main thread after the pool completes.
Configured in source/app/__init__.py when "worker" in sys.argv. Do not add
per-module db.session.remove() shims — they detach ORM objects passed in by
task_hook_wrapper.
create_safe matches on ALL kwargs; a name or description edit creates a duplicate.
Slug/key-keyed upsert:
existing = db.session.query(Skill).filter_by(slug=slug).first()
if not existing:
db.session.add(Skill(slug=slug, ...))The User ORM model stores the login name in .user, not .user_login. Using
.user_login raises AttributeError at runtime (gunicorn unhandled exception → HTTP 500
before the endpoint's own error handler runs).
| Field | Meaning |
|---|---|
User.user |
Login name (the value used to sign in) |
User.name |
Display name |
This has caused two separate HTTP 500 regressions (enqueue_ai_job and
suggest_analysts_for_case). Any code that builds user-display strings from a
User ORM object must use u.user for login and u.name for display name.
Its signature is response_api_error(message, data=None) and it always returns HTTP
400. The second positional argument is data, not a status — so
return response_api_error("Backend unavailable", 503) # emits 400 with data: 503Several existing calls in the correlation blueprint do exactly this. When you need a
status other than 400, build the body via response() directly, keeping the same shape:
from app.blueprints.responses import response
return response(409, data={'message': '...', 'data': {'reason': 'manual_edit_present'}})/api/v2/ returns the payload directly — no {status, message, data} envelope.
Reading resp.data on a v2 response returns undefined.
fields.Integer(allow_none=True) accepts None and valid integers but raises
ValidationError on "". Jinja renders nullable integer fields as value="{{ x or '' }}"
(empty string when None), and serializeObject() serializes those as "".
Convert empty strings to null in the JS before JSON.stringify:
['retention_months', 'capacity_planning_window_months', 'some_other_int'].forEach(function(k) {
if (data_sent[k] === '') { data_sent[k] = null; }
});
post_request_api('/manage/settings/update', JSON.stringify(data_sent), true);This pattern applies to any settings form that has optional numeric fields rendered via
value="{{ settings.x or '' }}" in the template.
Modals shared between add (user=None) and edit (user=<dict>) paths:
{% if user and user.some_field is not none %} — not just {% if user.some_field ... %}.
x.attr is not none still evaluates x.attr and raises UndefinedError when x is None.
setuptools >= 81 removed pkg_resources. Two pinned transitive dependencies
still import it at module load on live application code paths:
| Package | Import site | Why it cannot simply be bumped |
|---|---|---|
docxcompose |
properties.py (bundled template read) |
The vendored docx_generator-0.8.0 wheel hard-pins docxcompose==1.1.2 (plus python-docx==1.1.2, docxtpl==0.19.0) — the deliberately frozen reporter stack |
graphene-sqlalchemy |
utils.py (installed-version checks) |
— |
The trap is that pip install succeeds and even import setuptools succeeds.
pkg_resources is a separate module, so its absence only surfaces when the app
boots and gunicorn crash-loops with ModuleNotFoundError: No module named 'pkg_resources'.
IRIS-NG resolves this without changing any dependency version:
source/patches/depatch_pkg_resources.py runs in the Dockerfile compile-image
stage after pip install and before the venv is copied to the final stage,
rewriting both imports to importlib.resources / importlib.metadata. The patch
is content-matched, idempotent, and fail-loud — it asserts every target it expects,
so a future dependency bump that moves the code fails the build instead of
silently regressing.
Acceptance bar for any setuptools major bump — "the image builds" is not enough:
-
app,workerandai_workerall boot healthy - Generate a
.docxreport (exercises thedocxcomposepath) - Build and query the GraphQL schema (exercises the
graphene-sqlalchemypath)
If a new dependency starts importing pkg_resources, add a target to
depatch_pkg_resources.py. Imports inside module docstrings (click_plugins),
CLI-only entry points (qrcode) and test apps (werkzeug) are not on the
application path and need no patch.
docker exec iriswebapp_app alembic -c /iriswebapp/app/alembic.ini revision \
--autogenerate -m "add_my_feature"Review the generated file — autogenerate doesn't catch everything. Always define
CHECK + UNIQUE constraints on the model's __table_args__ (not just the migration).
- Create
source/app/blueprints/rest/v2/<name>/__init__.py - Register the blueprint in
source/app/__init__.py - Follow the three-layer rule: blueprint → business → datamgmt
- Return data directly (no
response_success()wrapper) - Require CSRF on
POST/PUT/DELETE - Check access control with the existing
ac_api_requires()decorator
See AI Features → Adding a new AI orchestrator.
See Dual Timeline → Adding a new ingest source.
The inverse-chip pattern (violet pill chips in modal B linking back to modal A) is established across IOC↔Note and Asset↔Evidence. Chip styling:
- Background:
rgba(139, 92, 246, 0.12) - Border:
rgba(139, 92, 246, 0.35) - Text:
#d4c4ff - Deep-link:
?shared=<id>auto-opens the linked modal
Every workflow file needs an explicit permissions: block immediately after on::
permissions:
contents: readAdd job-level permissions: for any job needing additional scope — don't promote to
workflow level.
| Rule | File |
|---|---|
| Path injection: use allowlist dict, not f-string | is_safe_url |
Tag search: POST /tags/index not GET /tags/search/<term>
|
misp_sync_client.py |
Never log secret[:N] — log length instead |
seed_2025_test_case.py |
| Strip config sub-keys from printed output | iris_misp_sync_dev.py |
Open-redirect: allowlist paths starting with /, no scheme, no netloc, no //
|
is_safe_url |
IRIS-NG is the Community Edition — community-maintained and LGPL-3.0. Contributions are welcome.
| Branch | Role |
|---|---|
main |
The active development branch. New work lands here and releases are tagged from it. Pull requests target main.
|
upstream-fixes |
Created lazily if upstream DFIR-IRIS ships a bugfix worth cherry-picking |
An older develop branch exists in some clones but is retired — it has unrelated
history to main (no common ancestor) and should not be used as a PR base.
- Check the roadmap and open issues — the work may already be tracked.
- Follow CODESTYLE.md:
[ADD]/[FIX]/[IMP]/[DEL]commit prefixes, f-strings only, one import per line, module-prefixed function names (iocs_create). - Respect the three-layer rule — blueprints → business → datamgmt. Cross-layer imports
are forbidden; a blueprint must never
from app.datamgmt.... - Schema changes ship an Alembic migration and define any
CHECKconstraint on the ORM model's__table_args__(see the gotcha above —db.create_all()runs first). - Confirm the change actually deploys: check the dev-loop table at the top of this page
for whether your file needs a restart, a
docker cp, or a full rebuild.
Do not open a public issue. Follow SECURITY.md.