Initial curated EventLens source from 0d80d3b38dfb817285481665b9e71e4b8a0656a0
No files matched your search
@@ -0,0 +1,18 @@
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
frontend/test-results
|
||||
frontend/playwright-report
|
||||
backend/.venv
|
||||
**/__pycache__
|
||||
*.pyc
|
||||
.git
|
||||
.gitea
|
||||
*.log
|
||||
.env
|
||||
.env.*
|
||||
docs
|
||||
frontend/e2e
|
||||
frontend/test-results
|
||||
frontend/playwright-report
|
||||
backend/tests
|
||||
**/*.test.ts
|
||||
@@ -0,0 +1,12 @@
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.venv/
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
frontend/test-results/
|
||||
frontend/playwright-report/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -0,0 +1,45 @@
|
||||
FROM node:22-alpine@sha256:c610fcdfb1d5b4740dd70c284ed3cb16bb857e0f7166196e36a5501df7a3aa32 AS frontend
|
||||
WORKDIR /src/frontend
|
||||
COPY VERSION /src/VERSION
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci --include=dev
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM python:3.12-alpine@sha256:b64631e04e4920160c50fbe8d8df828f7f35f06f425cb44aa09bca53e708a35a AS python-deps
|
||||
WORKDIR /build/backend
|
||||
COPY backend/pyproject.toml backend/uv.lock ./
|
||||
ENV UV_PROJECT_ENVIRONMENT=/opt/venv
|
||||
RUN python -m pip install --no-cache-dir uv==0.10.0 \
|
||||
&& uv sync --locked --no-dev --no-install-project
|
||||
|
||||
FROM python:3.12-alpine@sha256:b64631e04e4920160c50fbe8d8df828f7f35f06f425cb44aa09bca53e708a35a AS runtime
|
||||
ARG EVENTLENS_REVISION=local
|
||||
WORKDIR /app
|
||||
ENV VIRTUAL_ENV=/opt/venv
|
||||
ENV PATH="/opt/venv/bin:${PATH}"
|
||||
ENV EVENTLENS_REVISION=${EVENTLENS_REVISION}
|
||||
LABEL org.opencontainers.image.revision="${EVENTLENS_REVISION}"
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV EVENTLENS_FRONTEND_DIR=/app/frontend-dist
|
||||
ENV TMPDIR=/tmp
|
||||
|
||||
RUN apk add --no-cache 'libuuid=2.42.3-r1' \
|
||||
&& addgroup -S -g 10001 eventlens \
|
||||
&& adduser -S -D -H -u 10001 -G eventlens -s /sbin/nologin eventlens
|
||||
|
||||
COPY --from=python-deps --chown=eventlens:eventlens /opt/venv /opt/venv
|
||||
COPY --chown=eventlens:eventlens VERSION /app/VERSION
|
||||
COPY --chown=eventlens:eventlens backend/app/ /app/backend/app/
|
||||
COPY --chown=eventlens:eventlens samples/ /app/samples/
|
||||
COPY --from=frontend --chown=eventlens:eventlens /src/frontend/dist/ /app/frontend-dist/
|
||||
|
||||
USER eventlens
|
||||
EXPOSE 8000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3).read()" || exit 1
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--app-dir", "/app/backend", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,12 @@
|
||||
.PHONY: api web test e2e build
|
||||
api:
|
||||
cd backend && python -m uv run --locked --extra dev uvicorn app.main:app --reload --port 8000
|
||||
web:
|
||||
cd frontend && npm run dev
|
||||
test:
|
||||
cd backend && python -m uv run --locked --extra dev pytest -W error::DeprecationWarning
|
||||
cd frontend && npm test
|
||||
e2e:
|
||||
cd frontend && npm run test:e2e
|
||||
build:
|
||||
cd frontend && npm run build
|
||||
@@ -0,0 +1,3 @@
|
||||
# Public source export
|
||||
|
||||
This source snapshot was generated from the private canonical repository at revision `0d80d3b38dfb817285481665b9e71e4b8a0656a0`. It contains no private Git history or operational evidence. Changes are published from the canonical repository.
|
||||
@@ -0,0 +1,421 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"sourceRevision": "0d80d3b38dfb817285481665b9e71e4b8a0656a0",
|
||||
"files": [
|
||||
{
|
||||
"path": ".dockerignore",
|
||||
"sha256": "335ec52ea297e3a101ad78d4d5092859e78069c74d425bead6b28b66a11877ba",
|
||||
"bytes": 262
|
||||
},
|
||||
{
|
||||
"path": ".gitignore",
|
||||
"sha256": "e44cadf923d745d957695d6e41c1fbbd13fe1e80995862cba23895f13b4d49cc",
|
||||
"bytes": 162
|
||||
},
|
||||
{
|
||||
"path": "Dockerfile",
|
||||
"sha256": "1af5a5c6e247aea936a88ec48f3440bac88e8b068a457a6a20dd079fbe4b65ae",
|
||||
"bytes": 1898
|
||||
},
|
||||
{
|
||||
"path": "Makefile",
|
||||
"sha256": "4de1e258bf059469547b882efa9b06e055b38886c98de2fe4d69550232b62a6c",
|
||||
"bytes": 371
|
||||
},
|
||||
{
|
||||
"path": "README.md",
|
||||
"sha256": "d4c150cc0c4b5b58552e3df34c31b4c4ec5ef63fa9ecac382b2e4b841016c7ee",
|
||||
"bytes": 9460
|
||||
},
|
||||
{
|
||||
"path": "SECURITY.md",
|
||||
"sha256": "99a573a9d5d07e37f15054273058b591d621a7e56af3600d8779ae0889efd0ab",
|
||||
"bytes": 4911
|
||||
},
|
||||
{
|
||||
"path": "VERSION",
|
||||
"sha256": "3dd81e3d2bbe930a0c98531afa6c35bd862ee8a08458ec0d7d1b51138d7b3a3c",
|
||||
"bytes": 7
|
||||
},
|
||||
{
|
||||
"path": "backend/app/__init__.py",
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"bytes": 0
|
||||
},
|
||||
{
|
||||
"path": "backend/app/correlate.py",
|
||||
"sha256": "832ab5358bac0bd1a8f8dc8e764ada8cbfd3def70c745d19b7f76c99d2b47277",
|
||||
"bytes": 12832
|
||||
},
|
||||
{
|
||||
"path": "backend/app/demo.py",
|
||||
"sha256": "6ef93def5b21883b6d61755dedc9cded60122740bbcc032fe5039dd220bcb58f",
|
||||
"bytes": 339
|
||||
},
|
||||
{
|
||||
"path": "backend/app/main.py",
|
||||
"sha256": "42af827ba7da473ea4e5add0d6cf68ea09b4a26d641df1ce820c80c870a3dc8c",
|
||||
"bytes": 11730
|
||||
},
|
||||
{
|
||||
"path": "backend/app/models.py",
|
||||
"sha256": "dfdc0c5c9c34fdd41fbcd2f22f2418bbb8b2f321b50a11676bc735dfab57a37e",
|
||||
"bytes": 2995
|
||||
},
|
||||
{
|
||||
"path": "backend/app/normalize.py",
|
||||
"sha256": "e06271964395b7d428e2ad5c698fd71a3d520c7fbb17649ef2db29887b72aa3c",
|
||||
"bytes": 10740
|
||||
},
|
||||
{
|
||||
"path": "backend/app/parser.py",
|
||||
"sha256": "0481fb6da19753409bed633d6c5d1917a621eafe8666e30b3af4635b4134fa15",
|
||||
"bytes": 8559
|
||||
},
|
||||
{
|
||||
"path": "backend/app/version.py",
|
||||
"sha256": "4c5bdcb3640668ddeaab9a116d10662b22a60536587f38672df1b9b7e2a4fa21",
|
||||
"bytes": 698
|
||||
},
|
||||
{
|
||||
"path": "backend/pyproject.toml",
|
||||
"sha256": "4350e56b0e9af15cecccf04fc3fe8ce534f89a298e650828391aab54ca1e8bf8",
|
||||
"bytes": 514
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/fixtures/README.md",
|
||||
"sha256": "9208c039dd47b379fda4272b3f48ada537083897f6a53b2d25f760e059ae68b4",
|
||||
"bytes": 816
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_api_privacy.py",
|
||||
"sha256": "7b65e5ff28fd2750dc9eff9646ed1b0823b2a332a0119cea31df698af6499357",
|
||||
"bytes": 6781
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_correlation.py",
|
||||
"sha256": "bc613454bacf8ae7e24435551a1395cfaf6374b42e9fd4561226a042e84fa344",
|
||||
"bytes": 1228
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_demo.py",
|
||||
"sha256": "8cc6e53f45edb49328a36ac1e8dc6d2abb77edd1538b8b5f5457dc6abdbfe21b",
|
||||
"bytes": 630
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_evtx_fixture.py",
|
||||
"sha256": "8c6215c8b34d26e34fb592337b62bcef07aab595b17ea65a9f693a40c0513459",
|
||||
"bytes": 780
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_normalize.py",
|
||||
"sha256": "3765500cdf7c7d69d0adf6f879d7200ad763ef7428018f1efd9d7eaec534b503",
|
||||
"bytes": 1014
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_post_v1_hardening.py",
|
||||
"sha256": "7a195245dc1ee77806b7c8abae3e5733d010ebba24d85c9a4191037e890202b2",
|
||||
"bytes": 11074
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_security_limits.py",
|
||||
"sha256": "42f10eebf5174926838f5fe254852abd16a7836d4129c92a9a510818314e1de3",
|
||||
"bytes": 3249
|
||||
},
|
||||
{
|
||||
"path": "backend/uv.lock",
|
||||
"sha256": "479198b6cfb74433368f5bb6e587fe6deb9657cc5ea95f291908f11079c07799",
|
||||
"bytes": 236644
|
||||
},
|
||||
{
|
||||
"path": "docker-compose.yml",
|
||||
"sha256": "046df4d1537e40e82105f23201dec87c5cabb7bc94c1904f981b4bcfbfdca47a",
|
||||
"bytes": 790
|
||||
},
|
||||
{
|
||||
"path": "docs/ARCHITECTURE.md",
|
||||
"sha256": "a5618036f0455405b5aad3e0b650971fc44123a1843d2234b9fe374688df6bf8",
|
||||
"bytes": 3942
|
||||
},
|
||||
{
|
||||
"path": "docs/CORRELATION.md",
|
||||
"sha256": "a74f2321610de5f7d7babadd8999a303c56a01a1c74aa6665bc69a949f4b8048",
|
||||
"bytes": 3826
|
||||
},
|
||||
{
|
||||
"path": "docs/PRIVACY.md",
|
||||
"sha256": "9bfdb5a72f5cc5eea14f3f586186e7d28055f7617075665ab6a5d347c487bbba",
|
||||
"bytes": 3164
|
||||
},
|
||||
{
|
||||
"path": "docs/assets/eventlens-demo-overview.png",
|
||||
"sha256": "c24060588edb7004eac6cbd3b2164d84911d868bbc15d7c1deb7eec4faf558a5",
|
||||
"bytes": 285470
|
||||
},
|
||||
{
|
||||
"path": "docs/assets/eventlens-redaction-export.png",
|
||||
"sha256": "5bed3e7ee9b3e1f96465a9f127a9ac18e358c02f23271478eb5734f05fdd6af0",
|
||||
"bytes": 265967
|
||||
},
|
||||
{
|
||||
"path": "frontend/e2e/accessibility.spec.ts",
|
||||
"sha256": "43908447c69cbac930c12afc39fba189a8598061f5989dbe40de079aaf732a21",
|
||||
"bytes": 2247
|
||||
},
|
||||
{
|
||||
"path": "frontend/e2e/api-contract.spec.ts",
|
||||
"sha256": "f920c5af3c04ad02b884a038a9799aa9a5b63e0e036269f6ffbf1c114b7de644",
|
||||
"bytes": 3355
|
||||
},
|
||||
{
|
||||
"path": "frontend/e2e/branding.spec.ts",
|
||||
"sha256": "9f133b4f15405efcc44cba21624b1eeb08bf3eede29d784e889acb7b7b8efef1",
|
||||
"bytes": 1355
|
||||
},
|
||||
{
|
||||
"path": "frontend/e2e/eventlens.spec.ts",
|
||||
"sha256": "d0ca700bf291787b6b2c551a2c9898c00d8af52e7c7803c1e44e7f7ce4b9cf3d",
|
||||
"bytes": 2742
|
||||
},
|
||||
{
|
||||
"path": "frontend/e2e/partial-and-error.spec.ts",
|
||||
"sha256": "6c95dcc14fd78047127ab7b24e93b9ba641f67fb270f0df882d3e8f3a9149aee",
|
||||
"bytes": 2437
|
||||
},
|
||||
{
|
||||
"path": "frontend/e2e/portfolio-assets.spec.ts",
|
||||
"sha256": "8e6a84162b994fc37f6cc63326ac4c9d4a405722d9ee15e9a0bce28537fb9a46",
|
||||
"bytes": 1899
|
||||
},
|
||||
{
|
||||
"path": "frontend/e2e/real-evtx.spec.ts",
|
||||
"sha256": "6bc637105498c3e10605572cd5e23cc751f7f0484565cce771baea506b117732",
|
||||
"bytes": 1149
|
||||
},
|
||||
{
|
||||
"path": "frontend/e2e/responsive.spec.ts",
|
||||
"sha256": "fecd2af0a7037e52f669a718484df4f9d43b126c95ee1e552b58a358fa27d722",
|
||||
"bytes": 1197
|
||||
},
|
||||
{
|
||||
"path": "frontend/e2e/settings-storage-failure.spec.ts",
|
||||
"sha256": "a4662e7edade225558561fecede0bbb1801a2f873996a2dc712a2728e234ef8c",
|
||||
"bytes": 1580
|
||||
},
|
||||
{
|
||||
"path": "frontend/e2e/settings.spec.ts",
|
||||
"sha256": "270d801f4ee2773ffb20e4e107f8864181acc17f44532eb4ca4f29d0e44ac761",
|
||||
"bytes": 3469
|
||||
},
|
||||
{
|
||||
"path": "frontend/e2e/virtualization.spec.ts",
|
||||
"sha256": "f40284f9ff7a57e689c62b369b03281c7d554791889738447caa78af95ed1a03",
|
||||
"bytes": 1433
|
||||
},
|
||||
{
|
||||
"path": "frontend/e2e/workspace.spec.ts",
|
||||
"sha256": "9ffbd7c6df504990c52b7426b84d23f8121fa4d5a61bdbdc87e7c8f251bd5f59",
|
||||
"bytes": 4606
|
||||
},
|
||||
{
|
||||
"path": "frontend/eslint.config.mjs",
|
||||
"sha256": "a44f173c1c676000fdfdbb4a00653a350b7c79ff33905e7459af60141fa5f3d3",
|
||||
"bytes": 1468
|
||||
},
|
||||
{
|
||||
"path": "frontend/index.html",
|
||||
"sha256": "e5c50877769b161ae8e2390b9493fb8189c8d7cab464f561b4eb695eec7177ab",
|
||||
"bytes": 866
|
||||
},
|
||||
{
|
||||
"path": "frontend/package-lock.json",
|
||||
"sha256": "80d66b567277ebe9a8e80889879712b1d95a0cf412971f5789320fa8e4a22bf4",
|
||||
"bytes": 198707
|
||||
},
|
||||
{
|
||||
"path": "frontend/package.json",
|
||||
"sha256": "4ecf33f663fcaeb947dfe6fc70e2206daf1653103e6f35ff50ca44ffcc455342",
|
||||
"bytes": 805
|
||||
},
|
||||
{
|
||||
"path": "frontend/playwright.config.ts",
|
||||
"sha256": "41799d03b9057169815e739d1ba27a8663ad927a67b70be81dc757b42ebba30b",
|
||||
"bytes": 867
|
||||
},
|
||||
{
|
||||
"path": "frontend/public/apple-touch-icon.png",
|
||||
"sha256": "94f9b61268f3df1f8eb82e0c655bada5a31e5e235a4f39af43b6af8d97187a36",
|
||||
"bytes": 14307
|
||||
},
|
||||
{
|
||||
"path": "frontend/public/eventlens-icon-192.png",
|
||||
"sha256": "65b2a5e56748318a6521cac564fd318b1fe5d44e8043583af4851657ab06afe6",
|
||||
"bytes": 13829
|
||||
},
|
||||
{
|
||||
"path": "frontend/public/eventlens-icon-512.png",
|
||||
"sha256": "ed72d01fbbcb1a42767a4f0ddf3753e968c6ae20d9da04e7259082de2e1f4be4",
|
||||
"bytes": 19587
|
||||
},
|
||||
{
|
||||
"path": "frontend/public/favicon.ico",
|
||||
"sha256": "15bfcabc811d370f9c264b993082f1134cbd64110f4f88f97c0df7da961a7c02",
|
||||
"bytes": 6050
|
||||
},
|
||||
{
|
||||
"path": "frontend/public/favicon.svg",
|
||||
"sha256": "ec7ab2dc3bbfc49f509f564306c976da51d70e15e765e51bf92dbae256a59c0f",
|
||||
"bytes": 646
|
||||
},
|
||||
{
|
||||
"path": "frontend/public/site.webmanifest",
|
||||
"sha256": "e8ecd0422ff69eaf31da44605039816c01d69838e4243f56dc16cd28957899e5",
|
||||
"bytes": 630
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/app/App.tsx",
|
||||
"sha256": "4fdac8c7bbb3bdee747bda015de40c7991493d0fde6a0ad46a6419a2f330d116",
|
||||
"bytes": 16742
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/app/format.ts",
|
||||
"sha256": "980648a970eef09d6bf2f81d481a7bd441aaa4f6a98c37c538bda37e4f09aff6",
|
||||
"bytes": 2780
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/app/model.ts",
|
||||
"sha256": "7fa5a714f0686a9ee461f89c7a552f508cc30b2db09cec8c3dbebf36565628b8",
|
||||
"bytes": 2857
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/app/validation.test.ts",
|
||||
"sha256": "a90a4f313fa41765d259a322b45611a66f5b589cf8ad75390f958fb80f3a250b",
|
||||
"bytes": 3065
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/app/validation.ts",
|
||||
"sha256": "d6d87231ba58269a2c37ffe98923078645cd4c11bd5210a3cb84d10d8286d95b",
|
||||
"bytes": 4547
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/components/icons.tsx",
|
||||
"sha256": "f40aa04da4b58ef561c41a2af1b08119678d9362057b70612e96b0fa297dd8b6",
|
||||
"bytes": 3775
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/components/shell.tsx",
|
||||
"sha256": "12208aa1454ca175017487b3d20e038dd7e947a49db270ab9243d8d8a4c85806",
|
||||
"bytes": 4613
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/components/states.tsx",
|
||||
"sha256": "c23c32d65e58d9eba3af8033f8052209089d2d525477f17eb12155e2219dbb31",
|
||||
"bytes": 4854
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/components/timeline.tsx",
|
||||
"sha256": "21dd8ce0bcc049f5cd4a32d5dda973030f65615432da4dc449e21835dad06b5b",
|
||||
"bytes": 14520
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/export.test.ts",
|
||||
"sha256": "268c710a57c1fb860c0f6092af4433e751c75d1465bfb8d76ac43fee502ee74f",
|
||||
"bytes": 3144
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/export.ts",
|
||||
"sha256": "c0f71ab393db8263dffb679d3ef3f10d881a9ed2cb3485035a10fd44cf9302bf",
|
||||
"bytes": 4831
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/filter.performance.test.ts",
|
||||
"sha256": "720cf8994e6e898d4560ed1cf91ef019e684d3da41c2f4e34e3c7b4d3daf356d",
|
||||
"bytes": 1092
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/filter.test.ts",
|
||||
"sha256": "c0c42d1d3a78e2d956c13a528fded79b9cb7025f98124d4b70eeda2212bfd644",
|
||||
"bytes": 2348
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/filter.ts",
|
||||
"sha256": "d2e1595251138fbfa1d7b9b768b004a2ab540f1a85fd558c0b75eec3516472d0",
|
||||
"bytes": 2443
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/main.tsx",
|
||||
"sha256": "7769fc3cb8a7e6b4fda3a8c8b49e0c046f34a2d5dbc71c23093bcfab83f2bf9e",
|
||||
"bytes": 341
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/redact.test.ts",
|
||||
"sha256": "8e0715734dcd61d51bc41239db3190f046da7de78048930b162ce599024899a8",
|
||||
"bytes": 4557
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/redact.ts",
|
||||
"sha256": "0c9e30ad90bb19ada06024c8f7a91252beba8aa193899f6f084749b5afeb0cf2",
|
||||
"bytes": 7930
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/styles.css",
|
||||
"sha256": "ca14e2cca59c1a0e4f2a8f79aaefb6c3c756a4da91e943f34eda89fe12a0960f",
|
||||
"bytes": 43338
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/types.ts",
|
||||
"sha256": "13aa2a01ac49f3626da88017d6f06d4474fb9c781ed27bf4f7b0faef66e3675e",
|
||||
"bytes": 1217
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/vite-env.d.ts",
|
||||
"sha256": "424faf9241dd699dda995b367ed36665732da1e6ec1f33b2fd40394488ecac92",
|
||||
"bytes": 39
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/workspaces/AnalysisView.tsx",
|
||||
"sha256": "7b3e576fe130c02436ae1c8ab97f4054409718a382626ade0769330f08414d36",
|
||||
"bytes": 2813
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/workspaces/HuntingView.tsx",
|
||||
"sha256": "63965c7f11a7a0ffd5b345c562d985842b0546c00286f14defcd062e0deadb51",
|
||||
"bytes": 6348
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/workspaces/ReportsView.tsx",
|
||||
"sha256": "99f541f6ef4e775761c9117c1e4eb60821a80b1b490360884482ea9496a596d9",
|
||||
"bytes": 5416
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/workspaces/SettingsView.tsx",
|
||||
"sha256": "feafb35e6f54f934ad3d5214d6c79d9e8f22997efbbfc8a8e4eff2618b2ec3e2",
|
||||
"bytes": 8004
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/workspaces/TimelineView.tsx",
|
||||
"sha256": "1c0d571f57d6699c63f95a2c234fcbd5e5ae509c81cea42ef23a164c2f849cf1",
|
||||
"bytes": 2601
|
||||
},
|
||||
{
|
||||
"path": "frontend/tsconfig.json",
|
||||
"sha256": "715065df676b49f489e9fdf3b3d9bcc7d978d8e0b696a1d59918432006fd93d4",
|
||||
"bytes": 419
|
||||
},
|
||||
{
|
||||
"path": "frontend/vite.config.ts",
|
||||
"sha256": "789713c0deb31de7fab87deb6e3ff83294b33396d71915bfea94d8446bde6589",
|
||||
"bytes": 407
|
||||
},
|
||||
{
|
||||
"path": "frontend/vitest.config.ts",
|
||||
"sha256": "90ea927fec7f0a4e9608f65171ec0c748f965304c50be5cca4000a8f967d7d10",
|
||||
"bytes": 158
|
||||
},
|
||||
{
|
||||
"path": "samples/demo-events.json",
|
||||
"sha256": "55c0dab7dd2baf1d184e814cd139289c8fb5402952eb5127d54bf93865fc1e9b",
|
||||
"bytes": 1688
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
# EventLens-Public
|
||||
|
||||
EventLens is a local/self-hosted Windows Event Log incident reconstruction tool. It turns a real `.evtx` export into normalized evidence, deterministic incident windows and a readable timeline that helps answer: **what happened, in what order, and which records are probably related?**
|
||||
|
||||

|
||||
|
||||
## What it solves
|
||||
|
||||
Windows event logs are detailed but difficult to scan during an incident. EventLens provides a bounded workflow:
|
||||
|
||||
```text
|
||||
EVTX upload -> size/resource gate -> normalize -> deterministic correlation -> timeline/evidence inspector -> redacted export
|
||||
```
|
||||
|
||||
It preserves source evidence, makes every grouping explainable, and keeps the analysis local.
|
||||
|
||||
## Product boundary
|
||||
|
||||
EventLens is deliberately not a SIEM, a full Windows Event Viewer replacement, a cloud platform or an AI product. It has no database, authentication, telemetry, background worker, cloud account or LLM requirement. It focuses on one compact job: reconstructing a bounded incident story from Windows event records.
|
||||
|
||||
## v1.0.6 capabilities
|
||||
|
||||
- Parses real EVTX in memory with the Rust-backed `evtx` engine.
|
||||
- Enforces a 50 MiB file cap plus a receive-level request cap, 100,000-event cap, cooperative 8-second parse budget and bounded normalized output.
|
||||
- Reports `complete`, `partial` or `failed` parser status with stable issue codes, retained/skipped metrics and export metadata.
|
||||
- Normalizes System, EventData and UserData into a stable evidence contract; unreliable timestamps remain explicit and are never converted to epoch evidence.
|
||||
- Correlates provider/channel-scoped startup, shutdown, service, DNS, authentication and crash patterns using stable rule IDs, incident IDs and evidence event IDs.
|
||||
- Keeps unmatched warning/error evidence visible and compresses repeated signatures without deleting source events.
|
||||
- Supports drag/drop, search, category/level/provider filters, informational-noise suppression, incident focus and ±1/5/15/60-minute evidence windows.
|
||||
- Shows the rule, reason, time window and exact evidence IDs behind each incident.
|
||||
- Exports semantically equivalent JSON and Markdown in the browser. Redaction is enabled by default and previews host, identity, e-mail, IP, path and source-name replacement counts before export.
|
||||
- Uses `no-store` responses, security headers, bounded parse concurrency, sanitized source names and no intentional upload persistence.
|
||||
- Virtualizes large timelines so a 100,000-event analysis does not create 100,000 DOM rows.
|
||||
- Includes a deterministic demo fixture plus a pinned-hash public real-EVTX qualification fixture in CI and Playwright.
|
||||
- Provides five functional workspaces over the same in-memory analysis: Analysis overview, Timeline investigation, Hunting facets, Reports/export and local-only Settings.
|
||||
- Preserves workspace navigation and browser hash history without persisting event data; each workspace has an explicit empty state when no analysis is loaded.
|
||||
- Uses a responsive, keyboard-accessible shell with shared event filters, a virtualized timeline and an evidence inspector that remains available from Hunting results.
|
||||
- Ships one local, canonical evidence-lens mark across the top bar, browser favicon, touch/app icons and web manifest; it makes no network request and carries no tracking.
|
||||
- Keeps investigation orientation visible across Analysis, Timeline, Hunting and Reports with a compact source/completeness/evidence context strip and export-readiness summary.
|
||||
- Gives the existing browser-local preferences a dedicated, keyboard-accessible Settings workspace: native radio-backed option cards, a semantic export-redaction switch, explicit autosave feedback, a privacy boundary and a one-click preference reset.
|
||||
- Keeps source identity in one shared context strip, promotes parser completeness/skipped-record metrics in Analysis and reserves first-use guidance for the deterministic demo.
|
||||
|
||||
## Privacy and security
|
||||
|
||||
Event logs can contain usernames, hostnames, IP addresses, e-mail addresses, executable paths and application-specific data. EventLens:
|
||||
|
||||
- processes uploads only for the current request and closes the multipart handle;
|
||||
- never renders raw XML as markup and escapes untrusted values through React rendering;
|
||||
- bounds input size, parser records, fields, strings, normalized output and parse time;
|
||||
- bounds request bytes independently of `Content-Length` and admits only a small number of concurrent parses;
|
||||
- does not log event contents or write an application database;
|
||||
- previews best-effort redaction of hosts, identities (including domain accounts, SIDs and machine accounts), e-mails, IPv4/IPv6, Windows/UNC paths and source filenames before export.
|
||||
|
||||
Redaction is a privacy aid, not a formal DLP engine. Review an export before sharing it. See [SECURITY.md](SECURITY.md) and [docs/PRIVACY.md](docs/PRIVACY.md).
|
||||
|
||||
## Portfolio walkthrough
|
||||
|
||||
1. Open the deployed instance or start EventLens locally.
|
||||
2. Select **Load demo** for a deterministic six-event walkthrough.
|
||||
3. Review the source/completeness summary in **Analysis** and select an incident.
|
||||
4. Investigate the linked evidence in **Timeline** with search, facets, focus and time windows.
|
||||
5. Use **Hunting** for combined event-ID, provider, category, level, host and incident-membership searches.
|
||||
6. Review the redaction preview and download JSON or Markdown from **Reports**.
|
||||
7. Adjust UTC/local timestamps, density and default local preferences in **Settings**.
|
||||
|
||||

|
||||
|
||||
## Run locally
|
||||
|
||||
Backend (locked dependencies):
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python -m pip install uv==0.10.0
|
||||
python -m uv sync --locked --extra dev
|
||||
python -m uv run --locked --extra dev uvicorn app.main:app --reload --port 8000
|
||||
```
|
||||
|
||||
Frontend:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm ci
|
||||
npm test
|
||||
npm run lint
|
||||
npm run build
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The development UI uses `http://127.0.0.1:8000` for the API. Set `VITE_API_BASE_URL` when the API runs elsewhere. In a production image the UI and API are same-origin.
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Open [http://localhost:1252](http://localhost:1252). The production image uses digest-pinned base images, `npm ci`, `backend/uv.lock`, a non-root runtime user, dropped Linux capabilities, a read-only root filesystem and a bounded `/tmp` tmpfs. The health endpoint is [http://localhost:1252/health](http://localhost:1252/health); its `revision` is the exact injected build SHA (`local` for an unlabelled development build).
|
||||
|
||||
For an Unraid DockerMan-managed Compose deployment, the compose metadata publishes the WebUI URL and serves the durable canonical icon locally at `/eventlens-icon-512.png` through `net.unraid.docker.webui` and `net.unraid.docker.icon`. A broker-managed deployment must preserve those service labels for DockerMan to import them; EventLens does not claim DockerMan lifecycle ownership.
|
||||
|
||||
## Validation
|
||||
|
||||
The release gates are reproducible from a clean checkout:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python -m uv sync --locked --extra dev
|
||||
python -m uv run --locked --extra dev pytest -q -W error::DeprecationWarning
|
||||
python -m uv run --locked --extra dev ruff check .
|
||||
python -m uv run --locked --extra dev pip-audit --strict
|
||||
|
||||
cd ../frontend
|
||||
npm ci
|
||||
npm test
|
||||
npm run lint
|
||||
npm run build
|
||||
npx playwright install chromium
|
||||
npm run test:e2e
|
||||
npm audit --audit-level=moderate
|
||||
```
|
||||
|
||||
The private canonical repository maintains its own validation and deployment workflows. This public snapshot includes the application and reproducible tests; it does not include private infrastructure automation or deployment evidence.
|
||||
|
||||
## Deployment and access
|
||||
|
||||
Compose publishes port 1252 on all host interfaces. For local use, change the mapping to `127.0.0.1:1252:8000`. There is no built-in authentication; use network isolation and an authenticated HTTPS reverse proxy before allowing other users to upload sensitive evidence. Configure the build revision via `GITEA_COMMIT_SHA` when reproducible deployment identity is needed.
|
||||
|
||||
The screenshots show the deterministic bundled demo, not a real incident or an uploaded customer log.
|
||||
|
||||
## Limitations
|
||||
|
||||
- Correlation describes temporal/semantic relationships supported by evidence; it does not prove causality.
|
||||
- Rules are intentionally small and Windows-focused. Custom rules, hunting queries, alerting and retention are outside the product boundary.
|
||||
- The EVTX parser and browser analysis are bounded for safety; partial parses surface issues instead of pretending completeness.
|
||||
- Redaction is best-effort and application-specific values may still contain sensitive data.
|
||||
- A real Windows EVTX file is required for parser analysis; the bundled demo intentionally bypasses only the raw parser step.
|
||||
|
||||
## License
|
||||
|
||||
No project-wide license file is included in this repository. Publishing the source does not grant an open-source license. Normal copyright restrictions and the applicable terms for individual third-party components remain in effect.
|
||||
|
||||
## Public source distribution
|
||||
|
||||
The private canonical repository remains the development source. Reviewed snapshots are synchronized automatically to `EventLens-Public` after changes reach its default branch. The public export contains application source, dependency locks, generic deployment examples and selected demo visuals; private Git history, infrastructure workflows and operational records are excluded. `PUBLIC_SOURCE_MANIFEST.json` identifies the exact source revision and file hashes.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Security and privacy policy
|
||||
|
||||
EventLens is a defensive, local/self-hosted Windows Event Log analysis tool. It is designed for bounded incident reconstruction, not for centralized log retention or alerting.
|
||||
|
||||
## Data handling
|
||||
|
||||
- The selected `.evtx` file is processed at the request boundary and is not intentionally persisted by EventLens.
|
||||
- FastAPI/Starlette may use a transient multipart spool while receiving a request; the upload handle is explicitly closed in a `finally` block and EventLens does not copy it to an application file.
|
||||
- Analysis state lives in browser component memory for the current session. Only non-sensitive UI preferences are stored in the single `eventlens.preferences.v1` localStorage record; the Settings workspace exposes that boundary, saves preferences automatically and resets only that record. EventLens never stores event text, EVTX-derived values or analysis results there. There is no application database, account system, telemetry, analytics, cloud storage or third-party event upload.
|
||||
- API and health responses use `Cache-Control: no-store`; API responses also use `Pragma: no-cache`.
|
||||
- The API sanitizes uploaded filenames to a short basename before returning them. Export filenames are sanitized in the browser.
|
||||
- Event contents are not written to application logs. Runtime logs contain request paths/statuses only.
|
||||
|
||||
## Untrusted input controls
|
||||
|
||||
The EVTX/XML boundary is hostile-input handling, not trusted document parsing. The current limits are:
|
||||
|
||||
- 50 MiB file cap plus a 51 MiB request-envelope gate enforced while ASGI body messages are received, independent of `Content-Length`;
|
||||
- 100,000 normalized-event cap;
|
||||
- cooperative 8-second parser budget;
|
||||
- 2 MiB per parser-record JSON cap;
|
||||
- 200 normalized fields per event;
|
||||
- 256-character provider/channel/computer and 512-character summary caps;
|
||||
- 1,000-character normalized field-value cap, bounded field keys and a 16 MiB normalized-output budget;
|
||||
- deterministic single-thread parser execution with a process-local bounded admission gate (one active parse by default, with a configurable queue capped at four waiting requests);
|
||||
- generic parser errors and generic skipped-record issues without raw exception/event payloads; unexpected programmer/parser failures are logged only with stage, exception class and a non-data fingerprint and return a generic client error;
|
||||
- `complete`, `partial` and `failed` parse status. Partial status means records were omitted or parser recovery/truncation occurred; timestamp-quality notes alone do not make a complete record set look partial.
|
||||
|
||||
Malformed/corrupt files are rejected when no event can be recovered; partial recovery is marked in parse statistics. The Rust-backed `evtx` parser remains an external parser dependency and is isolated behind the parser module.
|
||||
|
||||
## Presentation and export safety
|
||||
|
||||
- Raw XML is never returned to or injected into the UI. The inspector exposes the normalized JSON contract.
|
||||
- React renders event text as values, not HTML.
|
||||
- Redaction works on a clone and previews host, identity, e-mail, IPv4/IPv6, Windows/UNC path and source-name replacements before export. Known structured identities are propagated into summaries, incident titles and reasons; domain accounts, SIDs and machine accounts are covered conservatively.
|
||||
- Markdown exports treat every event value as untrusted text: headings, links, images, HTML, tables and code-fence delimiters are escaped or placed inside a safe dynamic code span.
|
||||
- Redaction is best-effort; users must inspect reports before external sharing.
|
||||
- Correlation copy explicitly describes evidence-supported relationships and does not claim causality.
|
||||
|
||||
## Dependency and release controls
|
||||
|
||||
- Frontend release installs use `frontend/package-lock.json` and `npm ci`.
|
||||
- Python release installs use `backend/uv.lock` and `uv sync --locked`.
|
||||
- Base images are pinned by manifest digest in `Dockerfile`; the maintenance task is to update the tag and digest together and rerun the full validation.
|
||||
- CI runs Ruff, backend tests with deprecation warnings treated as errors, `pip-audit`, frontend lint/tests/typecheck/build, `npm audit`, deterministic Playwright journeys, Docker runtime smoke, a Trivy high/critical image scan and a CycloneDX image SBOM generation step.
|
||||
- The real-EVTX CI fixture is fetched from a pinned upstream tag and accepted only when its EVTX magic and SHA-256 match the recorded value.
|
||||
- The runtime container is non-root, read-only-compatible, uses a bounded `/tmp`, drops all Linux capabilities and enables `no-new-privileges`.
|
||||
|
||||
## Reporting issues
|
||||
|
||||
Use synthetic or already-public event samples. Never attach production logs containing credentials, personal data, confidential hostnames or internal network details to a public issue. Describe the smallest reproducible input and remove raw event payloads from screenshots and logs.
|
||||
@@ -0,0 +1,362 @@
|
||||
import hashlib
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from .models import Category, IncidentCluster, NormalizedEvent
|
||||
from .normalize import (
|
||||
is_application_error,
|
||||
is_dns_client,
|
||||
is_event_log,
|
||||
is_kernel_general,
|
||||
is_kernel_power,
|
||||
is_security_audit,
|
||||
is_service_control_manager,
|
||||
is_windows_error_reporting,
|
||||
)
|
||||
|
||||
TitleBuilder = Callable[[list[NormalizedEvent]], str]
|
||||
ReasonBuilder = Callable[[list[NormalizedEvent]], str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Rule:
|
||||
id: str
|
||||
category: Category
|
||||
severity: str
|
||||
window_seconds: int
|
||||
predicate: Callable[[NormalizedEvent], bool]
|
||||
key: Callable[[NormalizedEvent], str | None]
|
||||
title_for: TitleBuilder
|
||||
reason_for: ReasonBuilder
|
||||
|
||||
|
||||
def _field(event: NormalizedEvent, *names: str) -> str:
|
||||
for name in names:
|
||||
value = event.fields.get(name, "").strip()
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def _dimension(value: str) -> str | None:
|
||||
normalized = value.strip()
|
||||
if not normalized or normalized.casefold() in {
|
||||
"unknown",
|
||||
"unknown-account",
|
||||
"unknown-host",
|
||||
"unknown-process",
|
||||
"unknown-service",
|
||||
"n/a",
|
||||
"-",
|
||||
}:
|
||||
return None
|
||||
return normalized
|
||||
|
||||
|
||||
def _host(event: NormalizedEvent) -> str | None:
|
||||
return _dimension(event.computer)
|
||||
|
||||
|
||||
def _process(event: NormalizedEvent) -> str | None:
|
||||
return _dimension(_field(event, "process", "EventData.AppName", "EventData.Data[0]"))
|
||||
|
||||
|
||||
def _service(event: NormalizedEvent) -> str | None:
|
||||
return _dimension(_field(event, "service", "EventData.param1", "UserData.param1"))
|
||||
|
||||
|
||||
def _account(event: NormalizedEvent) -> str | None:
|
||||
return _dimension(_field(event, "EventData.TargetUserName", "TargetUserName"))
|
||||
|
||||
|
||||
def _group_key(*dimensions: str | None) -> str | None:
|
||||
if any(value is None for value in dimensions):
|
||||
return None
|
||||
return "|".join(value.casefold() for value in dimensions if value is not None)
|
||||
|
||||
|
||||
def _crash(event: NormalizedEvent) -> bool:
|
||||
return is_application_error(event.provider, event.channel, event.event_id) or is_windows_error_reporting(
|
||||
event.provider, event.channel, event.event_id
|
||||
)
|
||||
|
||||
|
||||
def _service_failure(event: NormalizedEvent) -> bool:
|
||||
return is_service_control_manager(event.provider, event.channel) and event.event_id in {7023, 7024, 7031, 7034}
|
||||
|
||||
|
||||
def _dns_timeout(event: NormalizedEvent) -> bool:
|
||||
return is_dns_client(event.provider, event.channel) and event.event_id == 1014
|
||||
|
||||
|
||||
def _failed_logon(event: NormalizedEvent) -> bool:
|
||||
return is_security_audit(event.provider, event.channel) and event.event_id == 4625
|
||||
|
||||
|
||||
def _normal_startup(event: NormalizedEvent) -> bool:
|
||||
return (is_kernel_general(event.provider, event.channel) and event.event_id == 12) or (
|
||||
is_event_log(event.provider, event.channel) and event.event_id == 6005
|
||||
)
|
||||
|
||||
|
||||
def _normal_shutdown(event: NormalizedEvent) -> bool:
|
||||
return (is_kernel_general(event.provider, event.channel) and event.event_id == 13) or (
|
||||
is_event_log(event.provider, event.channel) and event.event_id == 6006
|
||||
)
|
||||
|
||||
|
||||
def _unexpected_shutdown(event: NormalizedEvent) -> bool:
|
||||
return (is_kernel_power(event.provider, event.channel) and event.event_id == 41) or (
|
||||
is_event_log(event.provider, event.channel) and event.event_id == 6008
|
||||
)
|
||||
|
||||
|
||||
def _crash_title(evidence: list[NormalizedEvent]) -> str:
|
||||
ids = {event.event_id for event in evidence}
|
||||
if {1000, 1001}.issubset(ids):
|
||||
return "Application crash and reporting sequence"
|
||||
if 1000 in ids:
|
||||
return "Application crash recorded"
|
||||
return "Crash report recorded"
|
||||
|
||||
|
||||
def _crash_reason(evidence: list[NormalizedEvent]) -> str:
|
||||
ids = {event.event_id for event in evidence}
|
||||
if {1000, 1001}.issubset(ids):
|
||||
return "Application Error and Windows Error Reporting records were temporally related; no causal claim is made."
|
||||
return "A crash-related provider record was observed; a reporting sequence is not established by this evidence."
|
||||
|
||||
|
||||
def _service_title(evidence: list[NormalizedEvent]) -> str:
|
||||
return "Service failure window" if len(evidence) > 1 else "Service failure recorded"
|
||||
|
||||
|
||||
def _service_reason(evidence: list[NormalizedEvent]) -> str:
|
||||
service = _service(evidence[0])
|
||||
if service is None or _host(evidence[0]) is None:
|
||||
return "Service Control Manager reported a service failure; no service/host grouping was applied because a grouping dimension was unavailable."
|
||||
return "Service Control Manager reported an abnormal service termination for the same known service and host."
|
||||
|
||||
|
||||
def _dns_title(evidence: list[NormalizedEvent]) -> str:
|
||||
return "DNS timeout activity" if len(evidence) > 1 else "DNS timeout recorded"
|
||||
|
||||
|
||||
def _dns_reason(evidence: list[NormalizedEvent]) -> str:
|
||||
if _host(evidence[0]) is None:
|
||||
return "DNS Client reported a timeout; no host grouping was applied because the computer identity was unavailable."
|
||||
return "DNS Client timeout events were observed in the same bounded window on the same known host."
|
||||
|
||||
|
||||
def _auth_title(evidence: list[NormalizedEvent]) -> str:
|
||||
if len(evidence) >= 3:
|
||||
return "Failed sign-in burst"
|
||||
if len(evidence) == 2:
|
||||
return "Repeated failed sign-ins"
|
||||
return "Failed sign-in recorded"
|
||||
|
||||
|
||||
def _auth_reason(evidence: list[NormalizedEvent]) -> str:
|
||||
if _group_key(_host(evidence[0]), _account(evidence[0])) is None:
|
||||
return "Security Auditing reported a failed sign-in; no account/host grouping was applied because a grouping dimension was unavailable."
|
||||
return "Security Auditing reported failed sign-ins for the same known account and host; this is evidence of activity, not proof of cause."
|
||||
|
||||
|
||||
def _startup_title(evidence: list[NormalizedEvent]) -> str:
|
||||
return "Normal startup sequence" if len(evidence) > 1 else "Normal startup evidence"
|
||||
|
||||
|
||||
def _startup_reason(evidence: list[NormalizedEvent]) -> str:
|
||||
return "Kernel-General/EventLog startup records were temporally related; no cause is inferred."
|
||||
|
||||
|
||||
def _shutdown_title(evidence: list[NormalizedEvent]) -> str:
|
||||
return "Normal shutdown evidence"
|
||||
|
||||
|
||||
def _shutdown_reason(evidence: list[NormalizedEvent]) -> str:
|
||||
return "Kernel-General/EventLog shutdown evidence was observed; no cause is inferred."
|
||||
|
||||
|
||||
def _unexpected_title(evidence: list[NormalizedEvent]) -> str:
|
||||
return "Unexpected shutdown/restart evidence"
|
||||
|
||||
|
||||
def _unexpected_reason(evidence: list[NormalizedEvent]) -> str:
|
||||
return "Kernel-Power/EventLog records indicate an unexpected shutdown or restart; the logs do not establish why it occurred."
|
||||
|
||||
|
||||
RULES = (
|
||||
Rule(
|
||||
"windows.crash.application-report",
|
||||
"crash",
|
||||
"critical",
|
||||
120,
|
||||
_crash,
|
||||
lambda event: _group_key(_host(event), _process(event)),
|
||||
_crash_title,
|
||||
_crash_reason,
|
||||
),
|
||||
Rule(
|
||||
"windows.service.failure",
|
||||
"service",
|
||||
"warning",
|
||||
120,
|
||||
_service_failure,
|
||||
lambda event: _group_key(_host(event), _service(event)),
|
||||
_service_title,
|
||||
_service_reason,
|
||||
),
|
||||
Rule(
|
||||
"windows.network.dns-timeout",
|
||||
"network",
|
||||
"warning",
|
||||
120,
|
||||
_dns_timeout,
|
||||
lambda event: _group_key(_host(event)),
|
||||
_dns_title,
|
||||
_dns_reason,
|
||||
),
|
||||
Rule(
|
||||
"windows.auth.failed-logon",
|
||||
"authentication",
|
||||
"warning",
|
||||
120,
|
||||
_failed_logon,
|
||||
lambda event: _group_key(_host(event), _account(event)),
|
||||
_auth_title,
|
||||
_auth_reason,
|
||||
),
|
||||
Rule(
|
||||
"windows.system.startup",
|
||||
"system",
|
||||
"info",
|
||||
300,
|
||||
_normal_startup,
|
||||
lambda event: _group_key(_host(event)),
|
||||
_startup_title,
|
||||
_startup_reason,
|
||||
),
|
||||
Rule(
|
||||
"windows.system.shutdown.normal",
|
||||
"system",
|
||||
"info",
|
||||
300,
|
||||
_normal_shutdown,
|
||||
lambda event: _group_key(_host(event)),
|
||||
_shutdown_title,
|
||||
_shutdown_reason,
|
||||
),
|
||||
Rule(
|
||||
"windows.system.shutdown.unexpected",
|
||||
"system",
|
||||
"warning",
|
||||
300,
|
||||
_unexpected_shutdown,
|
||||
lambda event: _group_key(_host(event)),
|
||||
_unexpected_title,
|
||||
_unexpected_reason,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _event_sort_key(event: NormalizedEvent) -> tuple[bool, datetime, str]:
|
||||
reliable_timestamp = event.timestamp is not None and event.timestamp_valid
|
||||
return (
|
||||
not reliable_timestamp,
|
||||
event.timestamp if reliable_timestamp else datetime.max.replace(tzinfo=UTC),
|
||||
event.id,
|
||||
)
|
||||
|
||||
|
||||
def _groups_for_rule(events: list[NormalizedEvent], rule: Rule) -> list[list[NormalizedEvent]]:
|
||||
buckets: dict[str, list[NormalizedEvent]] = {}
|
||||
isolated_number = 0
|
||||
for event in events:
|
||||
if not rule.predicate(event) or event.timestamp is None or not event.timestamp_valid:
|
||||
continue
|
||||
key = rule.key(event)
|
||||
if key is None:
|
||||
isolated_number += 1
|
||||
key = f"__isolated__{isolated_number}:{event.id}"
|
||||
buckets.setdefault(key, []).append(event)
|
||||
groups: list[list[NormalizedEvent]] = []
|
||||
window = timedelta(seconds=rule.window_seconds)
|
||||
for key in sorted(buckets):
|
||||
ordered = sorted(buckets[key], key=_event_sort_key)
|
||||
current: list[NormalizedEvent] = []
|
||||
for event in ordered:
|
||||
if not current or event.timestamp - current[0].timestamp <= window:
|
||||
current.append(event)
|
||||
else:
|
||||
groups.append(current)
|
||||
current = [event]
|
||||
if current:
|
||||
groups.append(current)
|
||||
return groups
|
||||
|
||||
|
||||
def _incident_id(rule_id: str, evidence: list[NormalizedEvent]) -> str:
|
||||
seed = f"{rule_id}|{'|'.join(event.id for event in evidence)}".encode()
|
||||
return f"incident-{hashlib.sha256(seed).hexdigest()[:16]}"
|
||||
|
||||
|
||||
def _build(rule: Rule, evidence: list[NormalizedEvent]) -> IncidentCluster:
|
||||
signatures = {(event.provider, event.event_id, event.summary) for event in evidence}
|
||||
reasons = [rule.reason_for(evidence)]
|
||||
if len(evidence) > 1:
|
||||
seconds = int((evidence[-1].timestamp - evidence[0].timestamp).total_seconds())
|
||||
reasons.append(f"{len(evidence)} evidence events occurred within {seconds} second(s).")
|
||||
if len(signatures) < len(evidence):
|
||||
reasons.append(f"Repeated signals compress from {len(evidence)} records to {len(signatures)} distinct signature(s).")
|
||||
return IncidentCluster(
|
||||
id=_incident_id(rule.id, evidence),
|
||||
rule_id=rule.id,
|
||||
start=evidence[0].timestamp,
|
||||
end=evidence[-1].timestamp,
|
||||
title=rule.title_for(evidence),
|
||||
category=rule.category,
|
||||
severity=rule.severity,
|
||||
reasons=reasons,
|
||||
evidence_event_ids=[event.id for event in evidence],
|
||||
original_count=len(evidence),
|
||||
compressed_count=max(1, len(signatures)),
|
||||
)
|
||||
|
||||
|
||||
def correlate(events: list[NormalizedEvent]) -> list[IncidentCluster]:
|
||||
ordered = sorted(events, key=_event_sort_key)
|
||||
incidents: list[IncidentCluster] = []
|
||||
consumed: set[str] = set()
|
||||
for rule in RULES:
|
||||
for group in _groups_for_rule(ordered, rule):
|
||||
incidents.append(_build(rule, group))
|
||||
consumed.update(event.id for event in group)
|
||||
fallback_rule_id = "windows.generic.attention"
|
||||
for event in ordered:
|
||||
if event.id not in consumed and event.level.casefold() in {"critical", "error", "warning"}:
|
||||
incidents.append(
|
||||
IncidentCluster(
|
||||
id=_incident_id(fallback_rule_id, [event]),
|
||||
rule_id=fallback_rule_id,
|
||||
start=event.timestamp if event.timestamp_valid else None,
|
||||
end=event.timestamp if event.timestamp_valid else None,
|
||||
title=event.summary or "Unclassified attention event",
|
||||
category=event.category,
|
||||
severity="warning",
|
||||
reasons=["An error or warning was not matched by a more specific correlation rule."],
|
||||
evidence_event_ids=[event.id],
|
||||
original_count=1,
|
||||
compressed_count=1,
|
||||
)
|
||||
)
|
||||
return sorted(
|
||||
incidents,
|
||||
key=lambda incident: (
|
||||
incident.start is None,
|
||||
incident.start or datetime.max.replace(tzinfo=UTC),
|
||||
incident.rule_id,
|
||||
incident.id,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from .models import NormalizedEvent
|
||||
|
||||
|
||||
def load_demo() -> list[NormalizedEvent]:
|
||||
path = Path(__file__).resolve().parents[2] / "samples" / "demo-events.json"
|
||||
return [
|
||||
NormalizedEvent.model_validate(item)
|
||||
for item in json.loads(path.read_text(encoding="utf-8"))
|
||||
]
|
||||
@@ -0,0 +1,319 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, File, HTTPException, UploadFile
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.requests import ClientDisconnect
|
||||
|
||||
from .correlate import correlate
|
||||
from .demo import load_demo
|
||||
from .models import AnalysisResponse, ParseIssue, ParseStats
|
||||
from .parser import (
|
||||
MAX_UPLOAD_BYTES,
|
||||
EvtxParseError,
|
||||
UnexpectedParserError,
|
||||
parse_evtx_bytes,
|
||||
)
|
||||
from .version import VERSION, read_revision
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI(title="EventLens API", version=VERSION)
|
||||
cors_origins = [
|
||||
origin.strip()
|
||||
for origin in os.environ.get(
|
||||
"EVENTLENS_CORS_ORIGINS",
|
||||
"http://localhost:5173,http://127.0.0.1:5173,http://127.0.0.1:4173",
|
||||
).split(",")
|
||||
if origin.strip()
|
||||
]
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=cors_origins,
|
||||
allow_credentials=False,
|
||||
allow_methods=["GET", "POST"],
|
||||
allow_headers=["Content-Type"],
|
||||
)
|
||||
|
||||
MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024
|
||||
MAX_REQUEST_BYTES = MAX_UPLOAD_BYTES + MAX_MULTIPART_OVERHEAD_BYTES
|
||||
CONTENT_SECURITY_POLICY = (
|
||||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data:; connect-src 'self' http://localhost:8000 http://127.0.0.1:8000 "
|
||||
"http://localhost:5173 http://127.0.0.1:5173 http://127.0.0.1:4173; "
|
||||
"font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'"
|
||||
)
|
||||
SECURITY_HEADERS = {
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
|
||||
"Content-Security-Policy": CONTENT_SECURITY_POLICY,
|
||||
"X-Frame-Options": "DENY",
|
||||
}
|
||||
|
||||
|
||||
def _bounded_env_int(name: str, default: int, minimum: int, maximum: int) -> int:
|
||||
try:
|
||||
value = int(os.environ.get(name, str(default)))
|
||||
except (TypeError, ValueError):
|
||||
value = default
|
||||
return min(max(value, minimum), maximum)
|
||||
|
||||
|
||||
PARSE_CONCURRENCY = _bounded_env_int("EVENTLENS_PARSE_CONCURRENCY", 1, 1, 2)
|
||||
PARSE_QUEUE_LIMIT = _bounded_env_int("EVENTLENS_PARSE_QUEUE", 1, 0, 4)
|
||||
PARSE_QUEUE_TIMEOUT_SECONDS = 0.25
|
||||
_parse_slots = asyncio.Semaphore(PARSE_CONCURRENCY)
|
||||
_parse_admission_lock = asyncio.Lock()
|
||||
_parse_inflight = 0
|
||||
|
||||
|
||||
class RequestBodyLimitMiddleware:
|
||||
"""Bound request bytes before Starlette's multipart parser expands the upload."""
|
||||
|
||||
def __init__(self, app: Any, max_bytes: int) -> None:
|
||||
self.app = app
|
||||
self.max_bytes = max_bytes
|
||||
|
||||
async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
|
||||
if scope.get("type") != "http" or scope.get("path") != "/api/analyze" or scope.get("method") != "POST":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
headers = {key.lower(): value for key, value in scope.get("headers", [])}
|
||||
content_length = headers.get(b"content-length")
|
||||
if content_length is not None:
|
||||
try:
|
||||
declared = int(content_length)
|
||||
except (TypeError, ValueError):
|
||||
declared = 0
|
||||
if declared > self.max_bytes:
|
||||
await self._send_too_large(send)
|
||||
return
|
||||
|
||||
consumed = 0
|
||||
too_large = False
|
||||
response_started = False
|
||||
|
||||
async def limited_receive() -> dict[str, Any]:
|
||||
nonlocal consumed, too_large
|
||||
message = await receive()
|
||||
if message.get("type") == "http.request":
|
||||
consumed += len(message.get("body", b""))
|
||||
if consumed > self.max_bytes:
|
||||
too_large = True
|
||||
return {"type": "http.disconnect"}
|
||||
return message
|
||||
|
||||
async def guarded_send(message: dict[str, Any]) -> None:
|
||||
nonlocal response_started
|
||||
if too_large:
|
||||
if message.get("type") == "http.response.start" and not response_started:
|
||||
response_started = True
|
||||
await self._send_too_large(send)
|
||||
return
|
||||
await send(message)
|
||||
|
||||
try:
|
||||
await self.app(scope, limited_receive, guarded_send)
|
||||
except ClientDisconnect:
|
||||
if not too_large:
|
||||
raise
|
||||
if too_large and not response_started:
|
||||
await self._send_too_large(send)
|
||||
|
||||
async def _send_too_large(self, send: Any) -> None:
|
||||
body = json.dumps(
|
||||
{"detail": {"code": "request_body_too_large", "message": "Upload request exceeds the bounded request limit."}}
|
||||
).encode("utf-8")
|
||||
headers = [
|
||||
(b"content-type", b"application/json"),
|
||||
(b"content-length", str(len(body)).encode()),
|
||||
(b"cache-control", b"no-store"),
|
||||
(b"pragma", b"no-cache"),
|
||||
]
|
||||
headers.extend((name.lower().encode("ascii"), value.encode("ascii")) for name, value in SECURITY_HEADERS.items())
|
||||
await send(
|
||||
{
|
||||
"type": "http.response.start",
|
||||
"status": 413,
|
||||
"headers": headers,
|
||||
}
|
||||
)
|
||||
await send({"type": "http.response.body", "body": body})
|
||||
|
||||
|
||||
app.add_middleware(RequestBodyLimitMiddleware, max_bytes=MAX_REQUEST_BYTES)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def response_security_headers(request, call_next):
|
||||
response = await call_next(request)
|
||||
for name, value in SECURITY_HEADERS.items():
|
||||
response.headers[name] = value
|
||||
if request.url.path.startswith("/api/") or request.url.path == "/health":
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
return response
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {
|
||||
"status": "ok",
|
||||
"evtx_ingestion": True,
|
||||
"version": VERSION,
|
||||
"revision": read_revision(),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/demo", response_model=AnalysisResponse)
|
||||
def demo():
|
||||
events = load_demo()
|
||||
return AnalysisResponse(source="bundled-demo", events=events, incidents=correlate(events))
|
||||
|
||||
|
||||
async def _parse_with_admission(data: bytes):
|
||||
global _parse_inflight
|
||||
async with _parse_admission_lock:
|
||||
if _parse_inflight >= PARSE_CONCURRENCY + PARSE_QUEUE_LIMIT:
|
||||
raise HTTPException(
|
||||
503,
|
||||
detail={
|
||||
"code": "parse_capacity_exceeded",
|
||||
"message": "EventLens is already analyzing its maximum concurrent uploads; retry shortly.",
|
||||
},
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
_parse_inflight += 1
|
||||
acquired = False
|
||||
try:
|
||||
try:
|
||||
await asyncio.wait_for(_parse_slots.acquire(), timeout=PARSE_QUEUE_TIMEOUT_SECONDS)
|
||||
acquired = True
|
||||
except TimeoutError as exc:
|
||||
raise HTTPException(
|
||||
503,
|
||||
detail={
|
||||
"code": "parse_capacity_exceeded",
|
||||
"message": "EventLens is busy analyzing another upload; retry shortly.",
|
||||
},
|
||||
headers={"Retry-After": "1"},
|
||||
) from exc
|
||||
finally:
|
||||
if not acquired:
|
||||
async with _parse_admission_lock:
|
||||
_parse_inflight -= 1
|
||||
try:
|
||||
return await asyncio.to_thread(parse_evtx_bytes, data)
|
||||
finally:
|
||||
if acquired:
|
||||
_parse_slots.release()
|
||||
async with _parse_admission_lock:
|
||||
_parse_inflight -= 1
|
||||
|
||||
|
||||
def _failed_parse_stats() -> ParseStats:
|
||||
return ParseStats(
|
||||
status="failed",
|
||||
parsed_events=0,
|
||||
records_seen=0,
|
||||
skipped_records=0,
|
||||
issue_count=1,
|
||||
truncated=False,
|
||||
truncation_reason=None,
|
||||
duration_ms=0,
|
||||
issues=[ParseIssue(code="parser_rejected_input", message="The EVTX parser rejected the input.")],
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/analyze", response_model=AnalysisResponse)
|
||||
async def analyze(file: UploadFile = File(...)): # noqa: B008
|
||||
try:
|
||||
source = _safe_source_name(file.filename)
|
||||
if not source.lower().endswith(".evtx"):
|
||||
raise HTTPException(400, "Upload an .evtx file")
|
||||
data = bytearray()
|
||||
while True:
|
||||
chunk = await file.read(min(1024 * 1024, MAX_UPLOAD_BYTES + 1 - len(data)))
|
||||
if not chunk:
|
||||
break
|
||||
data.extend(chunk)
|
||||
if len(data) > MAX_UPLOAD_BYTES:
|
||||
raise HTTPException(413, "EVTX file exceeds the 50 MiB MVP limit")
|
||||
try:
|
||||
parsed = await _parse_with_admission(bytes(data))
|
||||
except EvtxParseError as exc:
|
||||
raise HTTPException(
|
||||
422,
|
||||
detail={
|
||||
"code": "parser_rejected_input",
|
||||
"message": str(exc),
|
||||
"parse": _failed_parse_stats().model_dump(mode="json"),
|
||||
},
|
||||
) from exc
|
||||
except UnexpectedParserError as exc:
|
||||
logger.error("Unexpected EVTX parser failure reached API exception_type=%s", type(exc).__name__)
|
||||
raise HTTPException(
|
||||
500,
|
||||
detail={"code": "parser_internal_error", "message": "EVTX analysis failed unexpectedly; retry the request."},
|
||||
) from exc
|
||||
if not parsed.events:
|
||||
raise HTTPException(
|
||||
422,
|
||||
detail={
|
||||
"code": "no_readable_events",
|
||||
"message": "No readable Windows events were found in this file.",
|
||||
"parse": ParseStats(
|
||||
status="failed",
|
||||
parsed_events=0,
|
||||
records_seen=parsed.records_seen,
|
||||
skipped_records=parsed.skipped_records,
|
||||
issue_count=len(parsed.issues),
|
||||
truncated=parsed.truncated,
|
||||
truncation_reason=parsed.truncation_reason,
|
||||
duration_ms=parsed.duration_ms,
|
||||
issues=parsed.issues,
|
||||
).model_dump(mode="json"),
|
||||
},
|
||||
)
|
||||
return AnalysisResponse(
|
||||
source=source,
|
||||
events=parsed.events,
|
||||
incidents=correlate(parsed.events),
|
||||
parse=ParseStats(
|
||||
status=parsed.status,
|
||||
parsed_events=len(parsed.events),
|
||||
records_seen=parsed.records_seen,
|
||||
skipped_records=parsed.skipped_records,
|
||||
issue_count=len(parsed.issues),
|
||||
truncated=parsed.truncated,
|
||||
truncation_reason=parsed.truncation_reason,
|
||||
duration_ms=parsed.duration_ms,
|
||||
issues=parsed.issues,
|
||||
),
|
||||
)
|
||||
finally:
|
||||
await file.close()
|
||||
|
||||
|
||||
def _safe_source_name(filename: str | None) -> str:
|
||||
"""Return a short display/export name without path or control characters."""
|
||||
candidate = re.split(r"[\\/]", filename or "")[-1]
|
||||
candidate = "".join(char for char in candidate if char.isprintable()).strip()
|
||||
candidate = re.sub(r"[^A-Za-z0-9._ -]", "_", candidate)
|
||||
candidate = candidate[:128]
|
||||
return candidate or "upload.evtx"
|
||||
|
||||
|
||||
frontend_dir = os.environ.get("EVENTLENS_FRONTEND_DIR")
|
||||
if frontend_dir:
|
||||
path = Path(frontend_dir)
|
||||
if path.is_dir():
|
||||
app.mount("/", StaticFiles(directory=str(path), html=True), name="frontend")
|
||||
@@ -0,0 +1,83 @@
|
||||
from datetime import UTC, datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
Category = Literal["system", "service", "network", "authentication", "application", "crash", "other"]
|
||||
ParseStatus = Literal["complete", "partial", "failed"]
|
||||
TimestampSource = Literal["event", "record", "missing", "invalid"]
|
||||
|
||||
|
||||
class NormalizedEvent(BaseModel):
|
||||
id: str = Field(max_length=128)
|
||||
timestamp: datetime | None = None
|
||||
timestamp_source: TimestampSource = "event"
|
||||
timestamp_valid: bool = True
|
||||
provider: str = Field(default="", max_length=256)
|
||||
event_id: int | None = Field(default=None, ge=0, le=4_294_967_295)
|
||||
level: str = Field(default="information", max_length=32)
|
||||
channel: str = Field(default="", max_length=256)
|
||||
computer: str = Field(default="", max_length=256)
|
||||
category: Category = "other"
|
||||
summary: str = Field(default="", max_length=512)
|
||||
fields: dict[str, str] = Field(default_factory=dict, max_length=200)
|
||||
|
||||
@field_validator("timestamp")
|
||||
@classmethod
|
||||
def normalize_naive_timestamp(cls, value: datetime | None) -> datetime | None:
|
||||
if value is not None and value.tzinfo is None:
|
||||
return value.replace(tzinfo=UTC)
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_timestamp_metadata(self) -> "NormalizedEvent":
|
||||
if self.timestamp is None:
|
||||
self.timestamp_valid = False
|
||||
if self.timestamp_source in {"event", "record"}:
|
||||
self.timestamp_source = "missing"
|
||||
elif self.timestamp_source in {"missing", "invalid"}:
|
||||
self.timestamp_valid = False
|
||||
else:
|
||||
self.timestamp_valid = True
|
||||
return self
|
||||
|
||||
|
||||
class IncidentCluster(BaseModel):
|
||||
id: str = Field(max_length=128)
|
||||
rule_id: str = Field(max_length=128)
|
||||
start: datetime | None = None
|
||||
end: datetime | None = None
|
||||
title: str = Field(max_length=256)
|
||||
category: Category
|
||||
severity: str = Field(max_length=32)
|
||||
reasons: list[str] = Field(max_length=8)
|
||||
evidence_event_ids: list[str]
|
||||
original_count: int = Field(ge=1)
|
||||
compressed_count: int = Field(ge=1)
|
||||
|
||||
|
||||
class ParseIssue(BaseModel):
|
||||
code: str = Field(max_length=64)
|
||||
record_id: int | None = None
|
||||
message: str = Field(max_length=256)
|
||||
skipped_record: bool = False
|
||||
|
||||
|
||||
class ParseStats(BaseModel):
|
||||
parser: str = "evtx-rs"
|
||||
status: ParseStatus
|
||||
parsed_events: int = Field(ge=0)
|
||||
records_seen: int = Field(ge=0)
|
||||
skipped_records: int = Field(ge=0)
|
||||
issue_count: int = Field(ge=0)
|
||||
truncated: bool
|
||||
truncation_reason: str | None = Field(default=None, max_length=64)
|
||||
duration_ms: int = Field(ge=0)
|
||||
issues: list[ParseIssue] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AnalysisResponse(BaseModel):
|
||||
source: str
|
||||
events: list[NormalizedEvent]
|
||||
incidents: list[IncidentCluster]
|
||||
parse: ParseStats | None = None
|
||||
@@ -0,0 +1,261 @@
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from .models import Category, NormalizedEvent, TimestampSource
|
||||
|
||||
AUTH_EVENT_IDS = {4624, 4625, 4634, 4647, 4648, 4672, 4768, 4769, 4771, 4776}
|
||||
LEVELS = {0: "information", 1: "critical", 2: "error", 3: "warning", 4: "information", 5: "verbose"}
|
||||
MAX_EVENT_ID = 4_294_967_295
|
||||
MAX_RECORD_ID = 9_223_372_036_854_775_807
|
||||
MAX_FIELDS = 200
|
||||
MAX_FIELD_KEY_CHARS = 256
|
||||
MAX_FIELD_VALUE_CHARS = 1000
|
||||
MAX_NESTING_DEPTH = 16
|
||||
MAX_PROVIDER_CHARS = 256
|
||||
MAX_CHANNEL_CHARS = 256
|
||||
MAX_COMPUTER_CHARS = 256
|
||||
MAX_SUMMARY_CHARS = 512
|
||||
|
||||
|
||||
def _bounded_text(value: Any, limit: int, fallback: str = "") -> str:
|
||||
if value is None:
|
||||
return fallback
|
||||
return str(value)[:limit]
|
||||
|
||||
|
||||
def _attributes(value: Any) -> dict[str, Any]:
|
||||
return value.get("#attributes", {}) if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _scalar(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
for key in ("#text", "Value", "value"):
|
||||
if key in value:
|
||||
return value[key]
|
||||
return value
|
||||
|
||||
|
||||
def _integer(value: Any, minimum: int = 0, maximum: int = MAX_RECORD_ID) -> int | None:
|
||||
value = _scalar(value)
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return None
|
||||
return parsed if minimum <= parsed <= maximum else None
|
||||
|
||||
|
||||
def _coerce_timestamp(value: Any) -> datetime | None:
|
||||
if isinstance(value, datetime):
|
||||
return value if value.tzinfo else value.replace(tzinfo=UTC)
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return None
|
||||
if text.endswith("Z"):
|
||||
text = f"{text[:-1]}+00:00"
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
|
||||
|
||||
|
||||
def _timestamp(system: dict[str, Any], fallback: Any) -> tuple[datetime | None, TimestampSource]:
|
||||
event_raw = _attributes(system.get("TimeCreated")).get("SystemTime")
|
||||
if event_raw is not None:
|
||||
parsed = _coerce_timestamp(event_raw)
|
||||
if parsed is not None:
|
||||
return parsed, "event"
|
||||
record_parsed = _coerce_timestamp(fallback)
|
||||
return (record_parsed, "record" if record_parsed is not None else "invalid")
|
||||
if fallback is not None:
|
||||
record_parsed = _coerce_timestamp(fallback)
|
||||
return (record_parsed, "record" if record_parsed is not None else "invalid")
|
||||
return None, "missing"
|
||||
|
||||
|
||||
def _provider(system: dict[str, Any]) -> str:
|
||||
value = system.get("Provider")
|
||||
if isinstance(value, dict):
|
||||
return _bounded_text(_attributes(value).get("Name") or value.get("Name"), MAX_PROVIDER_CHARS, "Unknown provider")
|
||||
return _bounded_text(value, MAX_PROVIDER_CHARS, "Unknown provider")
|
||||
|
||||
|
||||
def _store_field(output: dict[str, str], key: str, value: str, limit: int) -> None:
|
||||
bounded_key = _bounded_text(key or "value", MAX_FIELD_KEY_CHARS)
|
||||
if bounded_key not in output:
|
||||
output[bounded_key] = value
|
||||
return
|
||||
suffix = 2
|
||||
while len(output) < limit:
|
||||
suffix_key = _bounded_text(f"{bounded_key}#{suffix}", MAX_FIELD_KEY_CHARS)
|
||||
if suffix_key not in output:
|
||||
output[suffix_key] = value
|
||||
return
|
||||
suffix += 1
|
||||
|
||||
|
||||
def _flatten(
|
||||
prefix: str,
|
||||
value: Any,
|
||||
output: dict[str, str],
|
||||
limit: int = MAX_FIELDS,
|
||||
depth: int = 0,
|
||||
) -> None:
|
||||
if len(output) >= limit:
|
||||
return
|
||||
if depth > MAX_NESTING_DEPTH:
|
||||
_store_field(output, prefix or "value", "[nested value truncated]", limit)
|
||||
return
|
||||
if isinstance(value, dict):
|
||||
for key, child in value.items():
|
||||
if key == "#attributes":
|
||||
continue
|
||||
next_prefix = f"{prefix}.{key}" if prefix else str(key)
|
||||
_flatten(next_prefix, child, output, limit, depth + 1)
|
||||
if len(output) >= limit:
|
||||
return
|
||||
return
|
||||
if isinstance(value, list):
|
||||
for index, child in enumerate(value):
|
||||
_flatten(f"{prefix}[{index}]", child, output, limit, depth + 1)
|
||||
if len(output) >= limit:
|
||||
return
|
||||
return
|
||||
try:
|
||||
rendered = json.dumps(value, ensure_ascii=False) if not isinstance(value, str) else value
|
||||
except (TypeError, ValueError):
|
||||
rendered = "[unrenderable value]"
|
||||
_store_field(output, prefix or "value", rendered[:MAX_FIELD_VALUE_CHARS], limit)
|
||||
|
||||
|
||||
def _channel_matches(channel: str, *accepted: str) -> bool:
|
||||
return channel.strip().casefold() in {value.casefold() for value in accepted}
|
||||
|
||||
|
||||
def is_security_audit(provider: str, channel: str) -> bool:
|
||||
return provider.strip().casefold() == "microsoft-windows-security-auditing" and _channel_matches(channel, "Security")
|
||||
|
||||
|
||||
def is_service_control_manager(provider: str, channel: str) -> bool:
|
||||
return provider.strip().casefold() == "service control manager" and _channel_matches(channel, "System")
|
||||
|
||||
|
||||
def is_dns_client(provider: str, channel: str) -> bool:
|
||||
return provider.strip().casefold() == "microsoft-windows-dns-client" and _channel_matches(channel, "System")
|
||||
|
||||
|
||||
def is_application_error(provider: str, channel: str, event_id: int | None) -> bool:
|
||||
return provider.casefold().strip() == "application error" and event_id == 1000 and _channel_matches(channel, "Application")
|
||||
|
||||
|
||||
def is_windows_error_reporting(provider: str, channel: str, event_id: int | None) -> bool:
|
||||
return provider.casefold().strip() == "windows error reporting" and event_id == 1001 and _channel_matches(channel, "Application")
|
||||
|
||||
|
||||
def is_event_log(provider: str, channel: str) -> bool:
|
||||
return provider.casefold().strip() in {"eventlog", "microsoft-windows-eventlog"} and _channel_matches(channel, "System")
|
||||
|
||||
|
||||
def is_kernel_general(provider: str, channel: str) -> bool:
|
||||
return provider.strip().casefold() == "microsoft-windows-kernel-general" and _channel_matches(channel, "System")
|
||||
|
||||
|
||||
def is_kernel_power(provider: str, channel: str) -> bool:
|
||||
return provider.strip().casefold() == "microsoft-windows-kernel-power" and _channel_matches(channel, "System")
|
||||
|
||||
|
||||
def classify(provider: str, event_id: int | None, channel: str) -> Category:
|
||||
provider_lower = provider.casefold()
|
||||
channel_lower = channel.casefold()
|
||||
if is_service_control_manager(provider, channel):
|
||||
return "service"
|
||||
if is_dns_client(provider, channel) or any(
|
||||
token in provider_lower for token in ("networkprofile", "tcpip", "dhcp", "nlasvc")
|
||||
):
|
||||
return "network"
|
||||
if is_security_audit(provider, channel) and event_id in AUTH_EVENT_IDS:
|
||||
return "authentication"
|
||||
if is_application_error(provider, channel, event_id) or is_windows_error_reporting(provider, channel, event_id):
|
||||
return "crash"
|
||||
if (
|
||||
(is_kernel_general(provider, channel) and event_id in {12, 13})
|
||||
or (is_kernel_power(provider, channel) and event_id == 41)
|
||||
or (is_event_log(provider, channel) and event_id in {6005, 6006, 6008})
|
||||
):
|
||||
return "system"
|
||||
if "application" in channel_lower:
|
||||
return "application"
|
||||
return "other"
|
||||
|
||||
|
||||
def summarize(provider: str, event_id: int | None, category: Category, fields: dict[str, str]) -> str:
|
||||
if category == "service" and event_id in {7023, 7024, 7031, 7034}:
|
||||
service = fields.get("EventData.param1") or fields.get("UserData.param1") or fields.get("service")
|
||||
return f"{service} terminated unexpectedly" if service else "Service failure recorded"
|
||||
if category == "crash" and event_id == 1000:
|
||||
process = fields.get("EventData.AppName") or fields.get("EventData.Data[0]") or fields.get("process")
|
||||
return f"{process} crashed" if process else "Application crash recorded"
|
||||
if category == "authentication" and event_id == 4625:
|
||||
account = fields.get("EventData.TargetUserName") or fields.get("TargetUserName")
|
||||
return f"Failed sign-in for {account}" if account else "Failed sign-in recorded"
|
||||
return f"{provider} event {event_id}" if event_id is not None else f"{provider} event"
|
||||
|
||||
|
||||
def normalize_record(
|
||||
payload: dict[str, Any],
|
||||
record_id: Any = None,
|
||||
record_timestamp: Any = None,
|
||||
record_ordinal: int | None = None,
|
||||
) -> NormalizedEvent:
|
||||
if not isinstance(payload, dict):
|
||||
raise TypeError("Record does not contain an Event object")
|
||||
event = payload.get("Event", payload)
|
||||
if not isinstance(event, dict):
|
||||
raise TypeError("Record does not contain an Event object")
|
||||
system = event.get("System") if isinstance(event.get("System"), dict) else {}
|
||||
event_id = _integer(system.get("EventID"), maximum=MAX_EVENT_ID)
|
||||
provider = _provider(system)
|
||||
channel = _bounded_text(_scalar(system.get("Channel")), MAX_CHANNEL_CHARS)
|
||||
computer = _bounded_text(_scalar(system.get("Computer")), MAX_COMPUTER_CHARS)
|
||||
level_value = _integer(system.get("Level"), maximum=5)
|
||||
level = LEVELS.get(level_value, str(_scalar(system.get("Level")) or "information").lower())
|
||||
fields: dict[str, str] = {}
|
||||
if "EventData" in event:
|
||||
_flatten("EventData", event.get("EventData"), fields)
|
||||
if "UserData" in event:
|
||||
_flatten("UserData", event.get("UserData"), fields)
|
||||
category = classify(provider, event_id, channel)
|
||||
normalized_record_id = _integer(system.get("EventRecordID"))
|
||||
if normalized_record_id is None:
|
||||
normalized_record_id = _integer(record_id)
|
||||
if normalized_record_id is not None:
|
||||
stable_id = str(normalized_record_id)
|
||||
else:
|
||||
canonical_event = json.dumps(event, sort_keys=True, ensure_ascii=False, separators=(",", ":"), default=str)
|
||||
ordinal = "" if record_ordinal is None else str(record_ordinal)
|
||||
seed = f"{provider}|{event_id}|{record_timestamp}|{ordinal}|{canonical_event}".encode(
|
||||
"utf-8", errors="replace"
|
||||
)
|
||||
stable_id = f"event-{hashlib.sha256(seed).hexdigest()[:16]}"
|
||||
timestamp, timestamp_source = _timestamp(system, record_timestamp)
|
||||
summary = summarize(provider, event_id, category, fields)[:MAX_SUMMARY_CHARS]
|
||||
return NormalizedEvent(
|
||||
id=stable_id,
|
||||
timestamp=timestamp,
|
||||
timestamp_source=timestamp_source,
|
||||
timestamp_valid=timestamp is not None,
|
||||
provider=provider,
|
||||
event_id=event_id,
|
||||
level=level[:32],
|
||||
channel=channel,
|
||||
computer=computer,
|
||||
category=category,
|
||||
summary=summary,
|
||||
fields=fields,
|
||||
)
|
||||
@@ -0,0 +1,204 @@
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
from evtx import PyEvtxParser
|
||||
|
||||
from .models import NormalizedEvent, ParseIssue, ParseStatus
|
||||
from .normalize import normalize_record
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_EVENTS = 100_000
|
||||
MAX_PARSE_SECONDS = 8.0
|
||||
MAX_UPLOAD_BYTES = 50 * 1024 * 1024
|
||||
MAX_RECORD_JSON_BYTES = 2 * 1024 * 1024
|
||||
MAX_NORMALIZED_CHARS = 16 * 1024 * 1024
|
||||
|
||||
|
||||
class EvtxParseError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class UnexpectedParserError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParseResult:
|
||||
events: list[NormalizedEvent]
|
||||
issues: list[ParseIssue]
|
||||
truncated: bool
|
||||
duration_ms: int
|
||||
status: ParseStatus = "complete"
|
||||
truncation_reason: str | None = None
|
||||
records_seen: int = 0
|
||||
skipped_records: int = 0
|
||||
|
||||
|
||||
def _safe_record_id(value: object) -> int | None:
|
||||
try:
|
||||
parsed = int(value) if not isinstance(value, bool) else None
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return None
|
||||
return parsed if parsed is not None and 0 <= parsed <= 9_223_372_036_854_775_807 else None
|
||||
|
||||
|
||||
def _fingerprint(stage: str, exc: BaseException) -> str:
|
||||
return hashlib.sha256(f"{stage}:{type(exc).__name__}".encode("ascii", errors="replace")).hexdigest()[:12]
|
||||
|
||||
|
||||
def parse_evtx_bytes(
|
||||
data: bytes,
|
||||
max_events: int = MAX_EVENTS,
|
||||
max_seconds: float = MAX_PARSE_SECONDS,
|
||||
) -> ParseResult:
|
||||
if len(data) > MAX_UPLOAD_BYTES:
|
||||
raise EvtxParseError("EVTX file exceeds the 50 MiB MVP limit")
|
||||
max_events = min(max(1, int(max_events)), MAX_EVENTS)
|
||||
max_seconds = min(max(0.1, float(max_seconds)), MAX_PARSE_SECONDS)
|
||||
started = time.monotonic()
|
||||
events: list[NormalizedEvent] = []
|
||||
issues: list[ParseIssue] = []
|
||||
truncated = False
|
||||
truncation_reason: str | None = None
|
||||
records_seen = 0
|
||||
skipped_records = 0
|
||||
normalized_chars = 0
|
||||
seen_ids: set[str] = set()
|
||||
|
||||
def add_issue(
|
||||
code: str,
|
||||
message: str,
|
||||
record_id: int | None = None,
|
||||
skipped_record: bool = False,
|
||||
) -> None:
|
||||
nonlocal skipped_records
|
||||
issues.append(ParseIssue(code=code, record_id=record_id, message=message, skipped_record=skipped_record))
|
||||
if skipped_record:
|
||||
skipped_records += 1
|
||||
|
||||
try:
|
||||
parser = PyEvtxParser(io.BytesIO(data), number_of_threads=1)
|
||||
for ordinal, record in enumerate(parser.records_json()):
|
||||
records_seen += 1
|
||||
if len(events) >= max_events:
|
||||
truncated = True
|
||||
truncation_reason = "event_limit_reached"
|
||||
add_issue(
|
||||
"event_limit_reached",
|
||||
f"Event limit reached ({max_events}); this record and later records were not analyzed.",
|
||||
_safe_record_id(record.get("event_record_id")) if isinstance(record, dict) else None,
|
||||
skipped_record=True,
|
||||
)
|
||||
break
|
||||
if time.monotonic() - started > max_seconds:
|
||||
truncated = True
|
||||
truncation_reason = "parse_time_budget_reached"
|
||||
add_issue(
|
||||
"parse_time_budget_reached",
|
||||
f"Parse time budget reached ({max_seconds:.1f}s); later records were not analyzed.",
|
||||
_safe_record_id(record.get("event_record_id")) if isinstance(record, dict) else None,
|
||||
skipped_record=True,
|
||||
)
|
||||
break
|
||||
if not isinstance(record, dict):
|
||||
add_issue("malformed_record_skipped", "Skipped a malformed parser record.", skipped_record=True)
|
||||
continue
|
||||
record_id = _safe_record_id(record.get("event_record_id"))
|
||||
try:
|
||||
raw_data = record["data"]
|
||||
if not isinstance(raw_data, str):
|
||||
raise TypeError("record data was not textual")
|
||||
if (
|
||||
len(raw_data) > MAX_RECORD_JSON_BYTES
|
||||
or len(raw_data.encode("utf-8")) > MAX_RECORD_JSON_BYTES
|
||||
):
|
||||
add_issue(
|
||||
"record_json_limit_exceeded",
|
||||
"Skipped a parser record that exceeded the record JSON limit.",
|
||||
record_id,
|
||||
skipped_record=True,
|
||||
)
|
||||
continue
|
||||
payload = json.loads(raw_data)
|
||||
normalized = normalize_record(
|
||||
payload,
|
||||
record_id=record_id,
|
||||
record_timestamp=record.get("timestamp"),
|
||||
record_ordinal=ordinal,
|
||||
)
|
||||
normalized_size = len(normalized.provider) + len(normalized.channel) + len(normalized.computer)
|
||||
normalized_size += len(normalized.summary)
|
||||
normalized_size += sum(len(key) + len(value) for key, value in normalized.fields.items())
|
||||
if normalized_chars + normalized_size > MAX_NORMALIZED_CHARS:
|
||||
truncated = True
|
||||
truncation_reason = "normalized_output_budget_reached"
|
||||
add_issue(
|
||||
"normalized_output_budget_reached",
|
||||
"Normalized output budget reached; this record and later records were not analyzed.",
|
||||
record_id,
|
||||
skipped_record=True,
|
||||
)
|
||||
break
|
||||
if normalized.id in seen_ids:
|
||||
normalized.id = f"{normalized.id}-duplicate-{ordinal}"[:128]
|
||||
seen_ids.add(normalized.id)
|
||||
normalized_chars += normalized_size
|
||||
events.append(normalized)
|
||||
if normalized.timestamp_source in {"invalid", "missing"}:
|
||||
add_issue(
|
||||
"invalid_timestamp" if normalized.timestamp_source == "invalid" else "missing_timestamp",
|
||||
"Event timestamp is not reliable; the event remains evidence but is excluded from temporal correlation.",
|
||||
record_id,
|
||||
)
|
||||
except (KeyError, TypeError, ValueError, UnicodeError, json.JSONDecodeError, RecursionError):
|
||||
add_issue("malformed_record_skipped", "Skipped a malformed record.", record_id, skipped_record=True)
|
||||
except (OSError, ValueError, TypeError, UnicodeError, RuntimeError, EOFError, RecursionError) as exc:
|
||||
if not events:
|
||||
logger.warning(
|
||||
"EVTX parser rejected input stage=%s exception_type=%s fingerprint=%s",
|
||||
"open_or_iterate",
|
||||
type(exc).__name__,
|
||||
_fingerprint("open_or_iterate", exc),
|
||||
)
|
||||
raise EvtxParseError("The EVTX parser rejected the file or could not read its records.") from exc
|
||||
logger.warning(
|
||||
"EVTX parser recovered partially stage=%s exception_type=%s fingerprint=%s",
|
||||
"iterate",
|
||||
type(exc).__name__,
|
||||
_fingerprint("iterate", exc),
|
||||
)
|
||||
add_issue("parser_partial_recovery", "Parser stopped after partial recovery.")
|
||||
truncated = True
|
||||
truncation_reason = "parser_partial_recovery"
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Unexpected EVTX parser failure stage=%s exception_type=%s fingerprint=%s",
|
||||
"iterate",
|
||||
type(exc).__name__,
|
||||
_fingerprint("iterate", exc),
|
||||
)
|
||||
raise UnexpectedParserError("The EVTX parser encountered an unexpected internal failure.") from exc
|
||||
|
||||
duration_ms = int((time.monotonic() - started) * 1000)
|
||||
# A warning about an unreliable timestamp does not mean that the record was
|
||||
# omitted. Only truncation/recovery or an actually skipped record makes
|
||||
# the analysis partial; the issue remains visible in the parse metadata.
|
||||
incomplete = truncated or skipped_records > 0 or any(
|
||||
issue.code == "parser_partial_recovery" for issue in issues
|
||||
)
|
||||
status: ParseStatus = "partial" if incomplete else "complete"
|
||||
return ParseResult(
|
||||
events=events,
|
||||
issues=issues,
|
||||
truncated=truncated,
|
||||
duration_ms=duration_ms,
|
||||
status=status,
|
||||
truncation_reason=truncation_reason,
|
||||
records_seen=records_seen,
|
||||
skipped_records=skipped_records,
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
_VERSION_PATTERN = re.compile(r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$")
|
||||
_REVISION_PATTERN = re.compile(r"^[0-9a-f]{40}$")
|
||||
_VERSION_FILE = Path(__file__).resolve().parents[2] / "VERSION"
|
||||
LOCAL_REVISION = "local"
|
||||
|
||||
|
||||
def read_version() -> str:
|
||||
value = _VERSION_FILE.read_text(encoding="utf-8").strip()
|
||||
if not _VERSION_PATTERN.fullmatch(value):
|
||||
raise RuntimeError("Invalid EventLens VERSION file")
|
||||
return value
|
||||
|
||||
|
||||
def read_revision() -> str:
|
||||
value = os.environ.get("EVENTLENS_REVISION", "")
|
||||
if _REVISION_PATTERN.fullmatch(value):
|
||||
return value
|
||||
return LOCAL_REVISION
|
||||
|
||||
|
||||
VERSION = read_version()
|
||||
@@ -0,0 +1,21 @@
|
||||
[project]
|
||||
name = "eventlens-api"
|
||||
version = "1.0.6"
|
||||
description = "Local EVTX incident reconstruction API for EventLens"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"fastapi>=0.115,<1",
|
||||
"uvicorn[standard]>=0.34,<1",
|
||||
"python-multipart>=0.0.20,<1",
|
||||
"pydantic>=2.10,<3",
|
||||
"evtx>=0.12.1,<0.13"
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=9.0.3,<10", "httpx>=0.28,<1", "ruff>=0.9,<1", "pip-audit>=2.7,<3"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["."]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
@@ -0,0 +1,15 @@
|
||||
# EVTX integration fixture provenance
|
||||
|
||||
The binary fixture is intentionally not copied into this text-managed repository.
|
||||
|
||||
CI downloads `samples/Security_short_selected.evtx` from the upstream `omerbenamram/evtx` release tag `v0.12.2` and exposes it through `EVENTLENS_EVTX_FIXTURE`.
|
||||
|
||||
Expected contract:
|
||||
|
||||
- EVTX `ElfFile\0` header;
|
||||
- 7 records;
|
||||
- event IDs `5152, 4611, 4776, 4625, 5152, 5157, 4673`;
|
||||
- provider `Microsoft-Windows-Security-Auditing`;
|
||||
- channel `Security`.
|
||||
|
||||
The upstream EVTX project is dual-licensed MIT / Apache-2.0 and distributes this file as test data. The release tag `v0.12.2` corresponds to release commit `99a6def` (June 13, 2026). Keeping the fetch pinned to that release makes the integration contract reproducible while avoiding a binary-only write path in this repository.
|
||||
@@ -0,0 +1,171 @@
|
||||
import asyncio
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app import main
|
||||
from app.models import NormalizedEvent, ParseIssue
|
||||
from app.parser import ParseResult
|
||||
from app.version import VERSION
|
||||
|
||||
|
||||
def request(method: str, url: str, **kwargs) -> httpx.Response:
|
||||
async def send() -> httpx.Response:
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
|
||||
return await client.request(method, url, **kwargs)
|
||||
|
||||
return asyncio.run(send())
|
||||
|
||||
|
||||
def _fake_parse(_data: bytes) -> ParseResult:
|
||||
event = NormalizedEvent(id="1",timestamp=datetime(2026,9,9,tzinfo=UTC),provider="Test Provider",event_id=1000,level="error",channel="Application",computer="LAB-PC",category="crash",summary="Synthetic crash",fields={})
|
||||
return ParseResult(events=[event], issues=[], truncated=False, duration_ms=1)
|
||||
|
||||
|
||||
def test_api_sets_no_store_headers(monkeypatch):
|
||||
monkeypatch.setattr(main, "parse_evtx_bytes", _fake_parse)
|
||||
response = request("POST", "/api/analyze", files={"file": ("sample.evtx", b"synthetic", "application/octet-stream")})
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.headers["pragma"] == "no-cache"
|
||||
|
||||
|
||||
def test_api_sets_response_security_headers():
|
||||
response = request("GET", "/health")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["x-content-type-options"] == "nosniff"
|
||||
assert response.headers["referrer-policy"] == "no-referrer"
|
||||
assert response.headers["x-frame-options"] == "DENY"
|
||||
assert "frame-ancestors 'none'" in response.headers["content-security-policy"]
|
||||
|
||||
|
||||
def test_api_exposes_semantic_partial_parse_metrics(monkeypatch):
|
||||
def partial_parse(_data: bytes) -> ParseResult:
|
||||
event = _fake_parse(_data).events[0]
|
||||
return ParseResult(
|
||||
events=[event],
|
||||
issues=[ParseIssue(code="event_limit_reached", message="later records were not analyzed", skipped_record=True)],
|
||||
truncated=True,
|
||||
duration_ms=12,
|
||||
status="partial",
|
||||
truncation_reason="event_limit_reached",
|
||||
records_seen=4,
|
||||
skipped_records=1,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(main, "parse_evtx_bytes", partial_parse)
|
||||
response = request("POST", "/api/analyze", files={"file": ("sample.evtx", b"synthetic", "application/octet-stream")})
|
||||
assert response.status_code == 200
|
||||
parse = response.json()["parse"]
|
||||
assert parse["status"] == "partial"
|
||||
assert parse["parsed_events"] == 1
|
||||
assert parse["records_seen"] == 4
|
||||
assert parse["skipped_records"] == 1
|
||||
assert parse["issue_count"] == 1
|
||||
assert parse["issues"][0]["code"] == "event_limit_reached"
|
||||
|
||||
|
||||
def test_health_sets_no_store_headers():
|
||||
response = request("GET", "/health")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.headers["pragma"] == "no-cache"
|
||||
|
||||
|
||||
def test_health_uses_the_repository_version_source():
|
||||
response = request("GET", "/health")
|
||||
assert response.json()["version"] == VERSION
|
||||
|
||||
|
||||
def test_health_exposes_exact_build_revision_and_preserves_existing_fields(monkeypatch):
|
||||
revision = "0123456789abcdef0123456789abcdef01234567"
|
||||
monkeypatch.setenv("EVENTLENS_REVISION", revision)
|
||||
|
||||
response = request("GET", "/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"status": "ok",
|
||||
"evtx_ingestion": True,
|
||||
"version": VERSION,
|
||||
"revision": revision,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("revision", [None, "", "unknown", "A" * 40, "a" * 39, "a" * 64])
|
||||
def test_health_uses_deterministic_local_build_revision_fallback(monkeypatch, revision):
|
||||
if revision is None:
|
||||
monkeypatch.delenv("EVENTLENS_REVISION", raising=False)
|
||||
else:
|
||||
monkeypatch.setenv("EVENTLENS_REVISION", revision)
|
||||
|
||||
response = request("GET", "/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["revision"] == "local"
|
||||
|
||||
|
||||
def test_analysis_does_not_persist_upload_in_working_directory(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(main, "parse_evtx_bytes", _fake_parse)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
before = list(tmp_path.iterdir())
|
||||
response = request("POST", "/api/analyze", files={"file": ("sample.evtx", b"synthetic", "application/octet-stream")})
|
||||
after = list(tmp_path.iterdir())
|
||||
assert response.status_code == 200
|
||||
assert before == after == []
|
||||
|
||||
|
||||
def test_api_sanitizes_filename_in_analysis_source(monkeypatch):
|
||||
monkeypatch.setattr(main, "parse_evtx_bytes", _fake_parse)
|
||||
response = request(
|
||||
"POST",
|
||||
"/api/analyze",
|
||||
files={"file": (r"..\private\LAB-PC.evtx", b"synthetic", "application/octet-stream")},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["source"] == "LAB-PC.evtx"
|
||||
|
||||
|
||||
def test_api_rejects_oversized_upload_before_parser(monkeypatch):
|
||||
monkeypatch.setattr(main, "MAX_UPLOAD_BYTES", 8)
|
||||
response = request(
|
||||
"POST", "/api/analyze", files={"file": ("sample.evtx", b"123456789", "application/octet-stream")}
|
||||
)
|
||||
assert response.status_code == 413
|
||||
|
||||
|
||||
def test_request_limit_response_is_uncacheable_and_nosniff():
|
||||
response = request(
|
||||
"POST",
|
||||
"/api/analyze",
|
||||
headers={"Content-Length": str(main.MAX_REQUEST_BYTES + 1)},
|
||||
content=b"oversized",
|
||||
)
|
||||
assert response.status_code == 413
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.headers["pragma"] == "no-cache"
|
||||
assert response.headers["x-content-type-options"] == "nosniff"
|
||||
|
||||
|
||||
def test_api_rejects_when_parse_capacity_is_full(monkeypatch):
|
||||
import time
|
||||
|
||||
def slow_parse(_data: bytes) -> ParseResult:
|
||||
time.sleep(0.35)
|
||||
return _fake_parse(_data)
|
||||
|
||||
async def send_two():
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
|
||||
return await asyncio.gather(
|
||||
client.post("/api/analyze", files={"file": ("one.evtx", b"synthetic", "application/octet-stream")}),
|
||||
client.post("/api/analyze", files={"file": ("two.evtx", b"synthetic", "application/octet-stream")}),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(main, "parse_evtx_bytes", slow_parse)
|
||||
monkeypatch.setattr(main, "PARSE_QUEUE_LIMIT", 0)
|
||||
monkeypatch.setattr(main, "_parse_inflight", 0)
|
||||
responses = asyncio.run(send_two())
|
||||
assert sorted(response.status_code for response in responses) == [200, 503]
|
||||
@@ -0,0 +1,27 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from app.correlate import correlate
|
||||
from app.demo import load_demo
|
||||
from app.models import NormalizedEvent
|
||||
|
||||
|
||||
def test_demo_resolves_into_named_rules():
|
||||
rule_ids={incident.rule_id for incident in correlate(load_demo())}
|
||||
assert "windows.system.startup" in rule_ids
|
||||
assert "windows.service.failure" in rule_ids
|
||||
assert "windows.network.dns-timeout" in rule_ids
|
||||
assert "windows.crash.application-report" in rule_ids
|
||||
|
||||
|
||||
def test_repeated_dns_events_are_compressed_in_incident_summary():
|
||||
start=datetime(2026,9,9,8,0,tzinfo=UTC)
|
||||
events=[NormalizedEvent(id=str(index),timestamp=start+timedelta(seconds=index*10),provider="Microsoft-Windows-DNS-Client",event_id=1014,level="warning",channel="System",computer="LAB-PC",category="network",summary="DNS timeout") for index in range(3)]
|
||||
incident=correlate(events)[0]
|
||||
assert incident.rule_id == "windows.network.dns-timeout"
|
||||
assert incident.original_count == 3
|
||||
assert incident.compressed_count == 1
|
||||
|
||||
|
||||
def test_incident_ids_are_deterministic():
|
||||
events=load_demo()
|
||||
assert [incident.id for incident in correlate(events)] == [incident.id for incident in correlate(events)]
|
||||
@@ -0,0 +1,20 @@
|
||||
from app.correlate import correlate
|
||||
from app.demo import load_demo
|
||||
|
||||
|
||||
def test_demo_is_deterministic():
|
||||
events = load_demo()
|
||||
incidents = correlate(events)
|
||||
assert len(events) >= 6
|
||||
assert incidents
|
||||
assert all(incident.evidence_event_ids for incident in incidents)
|
||||
|
||||
|
||||
def test_demo_is_chronological_after_correlation():
|
||||
incidents = correlate(load_demo())
|
||||
assert incidents == sorted(incidents, key=lambda incident: incident.start)
|
||||
|
||||
|
||||
def test_demo_contains_portfolio_story_events():
|
||||
ids = {event.event_id for event in load_demo()}
|
||||
assert {12, 7031, 1014, 1000, 1001}.issubset(ids)
|
||||
@@ -0,0 +1,19 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.parser import parse_evtx_bytes
|
||||
|
||||
FIXTURE = os.environ.get("EVENTLENS_EVTX_FIXTURE")
|
||||
|
||||
|
||||
@pytest.mark.skipif(not FIXTURE, reason="EVENTLENS_EVTX_FIXTURE is only supplied by CI/release qualification")
|
||||
def test_pinned_real_evtx_fixture_end_to_end():
|
||||
data = Path(FIXTURE).read_bytes()
|
||||
assert data.startswith(b"ElfFile\x00")
|
||||
result = parse_evtx_bytes(data, max_events=100, max_seconds=10.0)
|
||||
assert len(result.events) == 7
|
||||
assert [event.event_id for event in result.events] == [5152, 4611, 4776, 4625, 5152, 5157, 4673]
|
||||
assert all(event.provider == "Microsoft-Windows-Security-Auditing" for event in result.events)
|
||||
assert all(event.channel == "Security" for event in result.events)
|
||||
@@ -0,0 +1,17 @@
|
||||
from app.normalize import classify, normalize_record
|
||||
|
||||
|
||||
def test_normalizes_rust_evtx_json_shape():
|
||||
payload = {"Event": {"System": {"Provider": {"#attributes": {"Name": "Microsoft-Windows-Security-Auditing"}},"EventID": 4625,"Level": 0,"Channel": "Security","Computer": "LAB-PC","EventRecordID": 77,"TimeCreated": {"#attributes": {"SystemTime": "2026-09-09T08:32:11.000000Z"}}},"EventData": {"TargetUserName": "jens", "Status": "0xc000006d"}}}
|
||||
event = normalize_record(payload)
|
||||
assert event.id == "77"
|
||||
assert event.event_id == 4625
|
||||
assert event.category == "authentication"
|
||||
assert event.summary == "Failed sign-in for jens"
|
||||
assert event.fields["EventData.Status"] == "0xc000006d"
|
||||
|
||||
|
||||
def test_classification_handles_common_windows_sources():
|
||||
assert classify("Service Control Manager", 7031, "System") == "service"
|
||||
assert classify("Microsoft-Windows-DNS-Client", 1014, "System") == "network"
|
||||
assert classify("Application Error", 1000, "Application") == "crash"
|
||||
@@ -0,0 +1,267 @@
|
||||
import asyncio
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from app import main, parser
|
||||
from app.correlate import correlate
|
||||
from app.models import NormalizedEvent
|
||||
from app.normalize import normalize_record
|
||||
from app.parser import UnexpectedParserError, parse_evtx_bytes
|
||||
|
||||
|
||||
def normalized(
|
||||
event_id: str,
|
||||
timestamp: datetime | None,
|
||||
*,
|
||||
provider: str,
|
||||
number: int,
|
||||
channel: str = "Security",
|
||||
computer: str = "LAB-PC",
|
||||
fields: dict[str, str] | None = None,
|
||||
level: str = "warning",
|
||||
category: str = "authentication",
|
||||
summary: str = "Synthetic event",
|
||||
) -> NormalizedEvent:
|
||||
return NormalizedEvent(
|
||||
id=event_id,
|
||||
timestamp=timestamp,
|
||||
provider=provider,
|
||||
event_id=number,
|
||||
level=level,
|
||||
channel=channel,
|
||||
computer=computer,
|
||||
category=category, # type: ignore[arg-type]
|
||||
summary=summary,
|
||||
fields=fields or {},
|
||||
)
|
||||
|
||||
|
||||
def record(provider: str, event_id: int, timestamp: str | None = "2026-09-09T08:00:00Z") -> dict:
|
||||
system = {"Provider": {"#attributes": {"Name": provider}}, "EventID": event_id}
|
||||
if timestamp is not None:
|
||||
system["TimeCreated"] = {"#attributes": {"SystemTime": timestamp}}
|
||||
return {"Event": {"System": system}}
|
||||
|
||||
|
||||
def test_invalid_timestamps_are_marked_and_never_become_epoch_evidence():
|
||||
event = normalize_record(record("Microsoft-Windows-Security-Auditing", 4625, "not-a-time"), record_timestamp="also-bad")
|
||||
assert event.timestamp is None
|
||||
assert event.timestamp_source == "invalid"
|
||||
assert event.timestamp_valid is False
|
||||
incident = correlate(
|
||||
[event.model_copy(update={"level": "warning", "category": "other", "channel": "Security", "computer": "LAB-PC"})]
|
||||
)[0]
|
||||
assert incident.start is None
|
||||
assert incident.end is None
|
||||
|
||||
|
||||
def test_valid_record_timestamp_is_an_explicit_fallback():
|
||||
event = normalize_record(record("Microsoft-Windows-Security-Auditing", 4625, "not-a-time"), record_timestamp="2026-09-09T08:00:00+02:00")
|
||||
assert event.timestamp == datetime(2026, 9, 9, 6, 0, tzinfo=UTC)
|
||||
assert event.timestamp_source == "record"
|
||||
assert event.timestamp_valid is True
|
||||
|
||||
|
||||
def test_invalid_record_timestamp_is_not_silent_missing_timestamp():
|
||||
event = normalize_record(record("Microsoft-Windows-Security-Auditing", 4625, timestamp=None), record_timestamp="not-a-time")
|
||||
assert event.timestamp is None
|
||||
assert event.timestamp_source == "invalid"
|
||||
assert event.timestamp_valid is False
|
||||
|
||||
|
||||
def test_unreliable_timestamp_metadata_cannot_enter_temporal_correlation():
|
||||
event = normalized(
|
||||
"unreliable",
|
||||
datetime(2026, 9, 9, 8, 0, tzinfo=UTC),
|
||||
provider="Microsoft-Windows-Security-Auditing",
|
||||
number=4625,
|
||||
).model_copy(update={"timestamp_source": "invalid", "timestamp_valid": False})
|
||||
incidents = correlate([event])
|
||||
assert incidents[0].start is None
|
||||
assert incidents[0].end is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("provider", "event_id", "channel", "expected"),
|
||||
[
|
||||
("Other Provider", 4625, "Security", "other"),
|
||||
("Other Provider", 1014, "System", "other"),
|
||||
("Other Provider", 1000, "Application", "application"),
|
||||
("Application Error", 1000, "System", "other"),
|
||||
("Windows Error Reporting", 1001, "Application", "crash"),
|
||||
],
|
||||
)
|
||||
def test_event_ids_are_scoped_to_provider_and_channel(provider, event_id, channel, expected):
|
||||
event = normalize_record(record(provider, event_id), record_timestamp="2026-09-09T08:00:00Z")
|
||||
event.channel = channel
|
||||
# Re-normalize the explicit channel because the helper record intentionally stays small.
|
||||
payload = record(provider, event_id)
|
||||
payload["Event"]["System"]["Channel"] = channel
|
||||
normalized_event = normalize_record(payload)
|
||||
assert normalized_event.category == expected
|
||||
|
||||
|
||||
def test_provider_rules_do_not_match_when_channel_context_is_missing():
|
||||
payload = record("Microsoft-Windows-Security-Auditing", 4625)
|
||||
normalized_event = normalize_record(payload)
|
||||
assert normalized_event.category == "other"
|
||||
incidents = correlate(
|
||||
[normalized("1", datetime(2026, 9, 9, 8, 0, tzinfo=UTC), provider=normalized_event.provider, number=4625, channel="", fields={})]
|
||||
)
|
||||
assert all(incident.rule_id != "windows.auth.failed-logon" for incident in incidents)
|
||||
|
||||
|
||||
def test_unknown_dimensions_are_isolated_and_one_failure_is_not_a_burst():
|
||||
start = datetime(2026, 9, 9, 8, 0, tzinfo=UTC)
|
||||
events = [
|
||||
normalized(str(index), start + timedelta(seconds=index), provider="Microsoft-Windows-Security-Auditing", number=4625, fields={})
|
||||
for index in range(2)
|
||||
]
|
||||
incidents = correlate(events)
|
||||
assert len(incidents) == 2
|
||||
assert all(incident.title == "Failed sign-in recorded" for incident in incidents)
|
||||
assert all("same known account" not in reason for incident in incidents for reason in incident.reasons)
|
||||
|
||||
|
||||
def test_known_failed_logons_use_thresholded_titles():
|
||||
start = datetime(2026, 9, 9, 8, 0, tzinfo=UTC)
|
||||
make = lambda index: normalized(
|
||||
str(index),
|
||||
start + timedelta(seconds=index),
|
||||
provider="Microsoft-Windows-Security-Auditing",
|
||||
number=4625,
|
||||
fields={"EventData.TargetUserName": "jens"},
|
||||
)
|
||||
assert correlate([make(0)])[0].title == "Failed sign-in recorded"
|
||||
assert correlate([make(0), make(1)])[0].title == "Repeated failed sign-ins"
|
||||
assert correlate([make(0), make(1), make(2)])[0].title == "Failed sign-in burst"
|
||||
|
||||
|
||||
def test_startup_and_unexpected_power_are_separate_rules():
|
||||
start = datetime(2026, 9, 9, 8, 0, tzinfo=UTC)
|
||||
events = [
|
||||
normalized("boot", start, provider="Microsoft-Windows-Kernel-General", number=12, channel="System", category="system", summary="OS started", level="information"),
|
||||
normalized("power", start + timedelta(seconds=20), provider="Microsoft-Windows-Kernel-Power", number=41, channel="System", category="system", summary="Unexpected restart"),
|
||||
]
|
||||
rules = {incident.rule_id for incident in correlate(events)}
|
||||
assert rules == {"windows.system.startup", "windows.system.shutdown.unexpected"}
|
||||
|
||||
|
||||
def test_parse_metrics_count_records_not_issue_messages(monkeypatch):
|
||||
class FakeParser:
|
||||
def __init__(self, *_args, **_kwargs):
|
||||
pass
|
||||
|
||||
def records_json(self):
|
||||
yield {"event_record_id": 1, "timestamp": "2026-09-09T08:00:00Z", "data": '{"Event":{"System":{"Provider":"P","EventID":1}}}'}
|
||||
yield {"event_record_id": 2, "timestamp": "2026-09-09T08:00:01Z", "data": "not-json"}
|
||||
|
||||
monkeypatch.setattr(parser, "PyEvtxParser", FakeParser)
|
||||
result = parse_evtx_bytes(b"ElfFile\x00synthetic")
|
||||
assert len(result.events) == 1
|
||||
assert result.records_seen == 2
|
||||
assert result.skipped_records == 1
|
||||
assert len(result.issues) == 1
|
||||
assert result.issues[0].code == "malformed_record_skipped"
|
||||
assert result.status == "partial"
|
||||
|
||||
|
||||
def test_timestamp_issue_does_not_claim_records_were_omitted(monkeypatch):
|
||||
class FakeParser:
|
||||
def __init__(self, *_args, **_kwargs):
|
||||
pass
|
||||
|
||||
def records_json(self):
|
||||
yield {"event_record_id": 1, "timestamp": "not-a-time", "data": '{"Event":{"System":{"Provider":"P","EventID":1}}}'}
|
||||
|
||||
monkeypatch.setattr(parser, "PyEvtxParser", FakeParser)
|
||||
result = parse_evtx_bytes(b"ElfFile\x00synthetic")
|
||||
assert result.status == "complete"
|
||||
assert result.skipped_records == 0
|
||||
assert result.issues[0].code == "invalid_timestamp"
|
||||
|
||||
|
||||
def test_event_limit_reports_one_observed_record_without_inventing_remaining_count(monkeypatch):
|
||||
class FakeParser:
|
||||
def __init__(self, *_args, **_kwargs):
|
||||
pass
|
||||
|
||||
def records_json(self):
|
||||
for index in range(3):
|
||||
yield {"event_record_id": index + 1, "timestamp": "2026-09-09T08:00:00Z", "data": '{"Event":{"System":{"Provider":"P","EventID":1}}}'}
|
||||
|
||||
monkeypatch.setattr(parser, "PyEvtxParser", FakeParser)
|
||||
result = parse_evtx_bytes(b"ElfFile\x00synthetic", max_events=2)
|
||||
assert len(result.events) == 2
|
||||
assert result.records_seen == 3
|
||||
assert result.skipped_records == 1
|
||||
assert result.truncation_reason == "event_limit_reached"
|
||||
assert result.issues[0].code == "event_limit_reached"
|
||||
|
||||
|
||||
def test_unexpected_parser_exception_is_not_disguised_as_partial(monkeypatch, caplog):
|
||||
class FakeParser:
|
||||
def __init__(self, *_args, **_kwargs):
|
||||
pass
|
||||
|
||||
def records_json(self):
|
||||
raise AssertionError("secret parser details")
|
||||
yield # pragma: no cover
|
||||
|
||||
monkeypatch.setattr(parser, "PyEvtxParser", FakeParser)
|
||||
with pytest.raises(UnexpectedParserError):
|
||||
parse_evtx_bytes(b"ElfFile\x00synthetic")
|
||||
assert "secret parser details" not in caplog.text
|
||||
|
||||
|
||||
def run_asgi(app, messages, headers=None):
|
||||
async def execute():
|
||||
sent = []
|
||||
pending = list(messages)
|
||||
|
||||
async def receive():
|
||||
return pending.pop(0) if pending else {"type": "http.disconnect"}
|
||||
|
||||
async def send(message):
|
||||
sent.append(message)
|
||||
|
||||
scope = {"type": "http", "method": "POST", "path": "/api/analyze", "headers": headers or [], "query_string": b"", "scheme": "http", "server": ("test", 80), "client": ("test", 1), "http_version": "1.1"}
|
||||
await app(scope, receive, send)
|
||||
return sent
|
||||
|
||||
return asyncio.run(execute())
|
||||
|
||||
|
||||
def test_receive_level_body_limit_handles_chunked_like_requests():
|
||||
async def sink(scope, receive, send):
|
||||
while (await receive()).get("type") != "http.disconnect":
|
||||
pass
|
||||
await send({"type": "http.response.start", "status": 200, "headers": []})
|
||||
await send({"type": "http.response.body", "body": b"ok"})
|
||||
|
||||
messages = [{"type": "http.request", "body": b"abc", "more_body": True}, {"type": "http.request", "body": b"d", "more_body": False}]
|
||||
sent = run_asgi(main.RequestBodyLimitMiddleware(sink, 3), messages)
|
||||
assert sent[0]["status"] == 413
|
||||
|
||||
|
||||
def test_receive_level_body_limit_accepts_exact_limit():
|
||||
async def sink(scope, receive, send):
|
||||
message = await receive()
|
||||
assert message["body"] == b"abc"
|
||||
await send({"type": "http.response.start", "status": 200, "headers": []})
|
||||
await send({"type": "http.response.body", "body": b"ok"})
|
||||
|
||||
sent = run_asgi(
|
||||
main.RequestBodyLimitMiddleware(sink, 3),
|
||||
[{"type": "http.request", "body": b"abc", "more_body": False}],
|
||||
)
|
||||
assert sent[0]["status"] == 200
|
||||
|
||||
|
||||
def test_receive_level_body_limit_handles_oversized_content_length():
|
||||
async def sink(scope, receive, send):
|
||||
raise AssertionError("downstream must not receive an oversized request")
|
||||
|
||||
sent = run_asgi(main.RequestBodyLimitMiddleware(sink, 3), [], headers=[(b"content-length", b"4")])
|
||||
assert sent[0]["status"] == 413
|
||||
@@ -0,0 +1,88 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from app import parser
|
||||
from app.correlate import correlate
|
||||
from app.models import NormalizedEvent
|
||||
from app.normalize import normalize_record
|
||||
from app.parser import EvtxParseError, parse_evtx_bytes
|
||||
|
||||
|
||||
def test_corrupt_evtx_has_a_generic_error_without_parser_details():
|
||||
with pytest.raises(EvtxParseError, match="rejected the file"):
|
||||
parse_evtx_bytes(b"not-an-evtx")
|
||||
|
||||
|
||||
def test_oversized_parser_record_is_skipped_before_json_expansion(monkeypatch):
|
||||
class FakeParser:
|
||||
def __init__(self, *_args, **_kwargs):
|
||||
pass
|
||||
|
||||
def records_json(self):
|
||||
yield {"event_record_id": "9" * 5000, "data": "x" * (parser.MAX_RECORD_JSON_BYTES + 1)}
|
||||
|
||||
monkeypatch.setattr(parser, "PyEvtxParser", FakeParser)
|
||||
result = parse_evtx_bytes(b"ElfFile\x00synthetic")
|
||||
assert result.events == []
|
||||
assert result.issues[0].code == "record_json_limit_exceeded"
|
||||
assert result.issues[0].record_id is None
|
||||
|
||||
|
||||
def test_normalization_bounds_nested_and_textual_values():
|
||||
payload = {
|
||||
"Event": {
|
||||
"System": {
|
||||
"Provider": {"#attributes": {"Name": "P" * 500}},
|
||||
"EventID": 1,
|
||||
"Channel": "C" * 500,
|
||||
"Computer": "H" * 500,
|
||||
"TimeCreated": {"#attributes": {"SystemTime": "2026-09-09T08:00:00Z"}},
|
||||
},
|
||||
"EventData": {"value": "V" * 5000},
|
||||
}
|
||||
}
|
||||
event = normalize_record(payload, record_ordinal=1)
|
||||
assert len(event.provider) == 256
|
||||
assert len(event.channel) == 256
|
||||
assert len(event.computer) == 256
|
||||
assert len(event.fields["EventData.value"]) == 1000
|
||||
|
||||
|
||||
def test_records_without_event_record_ids_get_distinct_deterministic_ids():
|
||||
payload = {
|
||||
"Event": {
|
||||
"System": {
|
||||
"Provider": "Synthetic",
|
||||
"EventID": 1,
|
||||
"TimeCreated": {"#attributes": {"SystemTime": "2026-09-09T08:00:00Z"}},
|
||||
}
|
||||
}
|
||||
}
|
||||
first = normalize_record(payload, record_timestamp="2026-09-09T08:00:00Z", record_ordinal=0)
|
||||
second = normalize_record(payload, record_timestamp="2026-09-09T08:00:00Z", record_ordinal=1)
|
||||
repeat = normalize_record(payload, record_timestamp="2026-09-09T08:00:00Z", record_ordinal=0)
|
||||
assert first.id != second.id
|
||||
assert first.id == repeat.id
|
||||
|
||||
|
||||
def test_correlation_does_not_bridge_beyond_rule_window():
|
||||
start = datetime(2026, 9, 9, 8, 0, tzinfo=UTC)
|
||||
events = [
|
||||
NormalizedEvent(
|
||||
id=str(index),
|
||||
timestamp=start + timedelta(seconds=offset),
|
||||
provider="Microsoft-Windows-DNS-Client",
|
||||
event_id=1014,
|
||||
level="warning",
|
||||
channel="System",
|
||||
computer="LAB-PC",
|
||||
category="network",
|
||||
summary="DNS timeout",
|
||||
)
|
||||
for index, offset in enumerate((0, 119, 238))
|
||||
]
|
||||
incidents = correlate(events)
|
||||
assert len(incidents) == 2
|
||||
assert [incident.original_count for incident in incidents] == [2, 1]
|
||||
assert all(incident.end - incident.start <= timedelta(seconds=120) for incident in incidents)
|
||||
@@ -0,0 +1,34 @@
|
||||
name: eventlens
|
||||
|
||||
x-itworx-deployment:
|
||||
purpose: broker-retry
|
||||
requested-at: "2026-09-10T12:07:00+02:00"
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
EVENTLENS_REVISION: ${GITEA_COMMIT_SHA:-local}
|
||||
container_name: eventlens
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "1252:8000"
|
||||
labels:
|
||||
net.unraid.docker.webui: "http://localhost:1252"
|
||||
net.unraid.docker.icon: "http://localhost:1252/eventlens-icon-512.png"
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:rw,noexec,nosuid,size=64m
|
||||
mem_limit: 1g
|
||||
cpus: 2.0
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
@@ -0,0 +1,40 @@
|
||||
# Architecture
|
||||
|
||||
```text
|
||||
Browser .evtx
|
||||
|
|
||||
v
|
||||
ASGI request/body + filename gates -> bounded in-memory bytes
|
||||
|
|
||||
v
|
||||
bounded parse admission -> Rust-backed evtx parser (single thread, time/event/record limits)
|
||||
|
|
||||
v
|
||||
normalized events (bounded strings/fields, explicit timestamp validity/source, stable IDs)
|
||||
|
|
||||
v
|
||||
deterministic correlation rules -> incidents + reasons + evidence IDs
|
||||
|
|
||||
v
|
||||
React workspace shell (Analysis/Timeline/Hunting/Reports/Settings) -> virtualized timeline/filter/inspector -> cloned redacted JSON/Markdown export
|
||||
```
|
||||
|
||||
## Boundaries
|
||||
|
||||
- `backend/app/main.py` owns HTTP, CORS, receive-level request limits, bounded parse admission, privacy headers, upload reading and response contracts.
|
||||
- `backend/app/parser.py` owns the untrusted EVTX boundary and never emits raw XML to presentation code.
|
||||
- `backend/app/normalize.py` maps parser-native JSON into `NormalizedEvent` values, with bounded fields, UTC timestamps and explicit `event`/`record`/`missing`/`invalid` timestamp metadata.
|
||||
- `backend/app/correlate.py` consumes normalized events only. It never parses raw EVTX, requires provider/channel context for provider-scoped IDs, avoids unknown grouping keys and attaches stable rule/incident/evidence identifiers.
|
||||
- `backend/app/models.py` defines the API contract.
|
||||
- `frontend/src/filter.ts`, `redact.ts` and `export.ts` are pure browser-side workflow functions.
|
||||
- `frontend/src/main.tsx` is only the React bootstrap. `frontend/src/app/App.tsx` owns explicit workspace state (active hash workspace, selection, filters, settings and loading/error state); reusable shell/state components live in `frontend/src/components/`, and Analysis, Timeline, Hunting, Reports and Settings live in `frontend/src/workspaces/` over the same in-memory `Analysis` object. The browser UI does not implement parser or correlation rules.
|
||||
|
||||
## Resource and privacy model
|
||||
|
||||
Uploads are bounded while ASGI body messages are received, then read in bounded chunks and parsed from bytes in a worker thread so the FastAPI event loop remains responsive. Each parser record is checked before JSON expansion. Normalized output has a global character budget in addition to per-event limits. The multipart handle is closed after the request and no upload path is retained.
|
||||
|
||||
API and health responses are non-cacheable and carry defensive browser headers. The production container runs as an unprivileged user with a read-only root filesystem, dropped capabilities and a bounded temporary filesystem. The browser keeps only the current normalized analysis in memory; large timelines are virtualized and search documents are cached per event.
|
||||
|
||||
The deployment broker passes the validated main commit as `GITEA_COMMIT_SHA` into the Compose build. Docker embeds that exact SHA as `EVENTLENS_REVISION`; `/health` exposes it with the product version without running Git in the container. An unlabelled local build reports `local`.
|
||||
|
||||
The browser stores only one validated local-preferences record in `localStorage` (`eventlens.preferences.v1`); it never stores events, analysis results or EVTX-derived values. Settings writes that record automatically, exposes an accessible save confirmation and can reset it to validated defaults. A configured default workspace is used only when no URL hash is present, so a hash/deep link always wins. Workspace navigation uses a lightweight URL hash so browser back/forward works without a routing dependency. The static browser-brand package (SVG/ICO/PNG/manifest) is bundled locally, has no service worker and does not add a cache or network dependency. The bundled JSON demo exercises the same normalized, correlation and presentation contracts while deliberately skipping only raw EVTX parsing. The real parser is qualified in CI against a public, hash-verified seven-record EVTX fixture without committing that fixture or its event data to the repository.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Correlation design
|
||||
|
||||
Correlation is deterministic and explainable. EventLens says records are temporally or semantically related; it does not invent causality.
|
||||
|
||||
Every incident carries a stable `rule_id`, bounded total time window, explicit predicate, grouping key, human-readable reason and exact evidence event IDs. Incident IDs are SHA-256-derived from the rule and ordered evidence IDs, so repeated analysis of identical normalized evidence yields the same identifiers.
|
||||
|
||||
## Ordering and identity
|
||||
|
||||
- Parser timestamps are normalized to timezone-aware UTC values. A valid event timestamp wins; a valid parser-record timestamp is an explicit fallback. Invalid or missing timestamps remain `null` with `timestamp_source`/`timestamp_valid` metadata and are excluded from temporal correlation, so no synthetic 1970 evidence is created.
|
||||
- Correlation sorts by `(timestamp, event.id)` before grouping and returns incidents by `(start, rule_id, id)`.
|
||||
- EVTX record IDs are retained as stable event IDs. When a parser record has no record ID, the parser ordinal plus canonical event content produces a deterministic hash; duplicate records at the same timestamp do not silently share an ID.
|
||||
- Evidence IDs are copied exactly into every incident. Repeated signatures remain in the evidence timeline and are represented by `original_count` versus `compressed_count` in incident metadata.
|
||||
|
||||
## Rules and boundaries
|
||||
|
||||
- `windows.crash.application-report` — Application Error 1000 and/or Windows Error Reporting 1001 from the exact Application provider/channel within 120 seconds, grouped by known host/process. The word “sequence” is reserved for evidence containing both records.
|
||||
- `windows.service.failure` — abnormal Service Control Manager 7023/7024/7031/7034 events from the System channel within 120 seconds, grouped by known host/service.
|
||||
- `windows.network.dns-timeout` — DNS Client 1014 events from the System channel in a bounded 120-second window per known host.
|
||||
- `windows.auth.failed-logon` — Security Auditing 4625 events from the Security channel in a bounded 120-second window per known host/account. One event is recorded evidence; two are repeated failures; three or more are called a burst.
|
||||
- `windows.system.startup` — Kernel-General 12 or EventLog 6005 startup signals in a bounded 300-second window per known host.
|
||||
- `windows.system.shutdown.normal` — Kernel-General 13 or EventLog 6006 normal-shutdown evidence in a bounded 300-second window per known host.
|
||||
- `windows.system.shutdown.unexpected` — Kernel-Power 41 or EventLog 6008 unexpected-shutdown/restart evidence in a bounded 300-second window per known host. The reason describes evidence, not a cause.
|
||||
- `windows.generic.attention` — preserves unmatched warning/error evidence rather than hiding it.
|
||||
|
||||
A rule requires its provider and channel context; a coincidental Event ID from another provider is not classified as that rule. Missing/placeholder host, process, service or account values create an event-isolated group and the reason says that grouping was unavailable; they never become a shared `unknown-*` key. Events without a reliable timestamp remain evidence but do not enter temporal groups.
|
||||
|
||||
A group is split when adding the next event would make the total span from the first event exceed the rule window. Equality at the boundary is included; one millisecond beyond it is split. This prevents a long chain of near-adjacent records from creating an unbounded incident.
|
||||
|
||||
Repeated identical provider/event/summary signatures remain in the evidence timeline but are compressed in the incident summary; source records and evidence IDs are never removed. Events may support more than one specifically matching rule when their predicates independently match; no rule is treated as proof of root cause.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Privacy model
|
||||
|
||||
Windows Event Logs frequently contain usernames, hostnames, IP addresses, e-mail addresses, executable paths and application-specific fields.
|
||||
|
||||
## In-memory workflow
|
||||
|
||||
1. The browser sends the selected `.evtx` to the self-hosted API.
|
||||
2. A receive-level request gate rejects oversized body streams before multipart parsing; the API then reads bounded chunks, parses bytes off the event loop and returns normalized JSON.
|
||||
3. EventLens does not create an application-level upload file or database record.
|
||||
4. The multipart parser may transiently spool request data as part of the web framework; the upload handle is closed after every request and the app does not retain that path.
|
||||
5. The browser keeps the normalized analysis in component memory for the current session; switching workspaces does not create another copy or upload it elsewhere.
|
||||
6. Only display preferences (timestamp mode, defaults, density and preferred workspace) may be stored in the single `eventlens.preferences.v1` localStorage record; event text, EVTX-derived values and analysis results are never stored there. The Settings workspace labels this boundary directly, exposes its technical key only on request and can reset the record to defaults.
|
||||
7. Export is generated locally in the browser.
|
||||
8. The favicon, touch icons and web manifest are static files bundled with EventLens. They make no third-party request, do not enable a service worker and do not persist investigation data.
|
||||
|
||||
## Redaction before export
|
||||
|
||||
Redaction is enabled by default and operates on a cloned analysis object. It masks or replaces:
|
||||
|
||||
- computer/host values;
|
||||
- username/account/e-mail identity fields, including domain-qualified accounts, SIDs and machine accounts;
|
||||
- e-mail addresses in free text;
|
||||
- IPv4 and IPv6 addresses in free text or IP-address fields;
|
||||
- Windows and UNC paths (including spaces/parentheses) and path-like fields;
|
||||
- source-name fields and source values propagated into free-form text;
|
||||
- known structured identities propagated into summaries, incident titles and reasons;
|
||||
- the source filename in the exported report.
|
||||
|
||||
The UI previews replacement counts by type before export. The original in-memory analysis is never modified by the redaction function. The source name is replaced with `[REDACTED_SOURCE].evtx` when redaction is enabled. JSON and Markdown carry the same redacted normalized analysis and parse-completeness metadata.
|
||||
|
||||
Redaction can be disabled when a raw forensic export is explicitly required, but that is an explicit user choice and the warning remains visible.
|
||||
|
||||
## Limits
|
||||
|
||||
The API enforces a 50 MiB file limit, a bounded request body independent of `Content-Length`, parser event/time budgets, bounded normalized strings/fields/output and bounded parse concurrency. These controls reduce accidental resource exhaustion; they are not a guarantee against every parser implementation vulnerability.
|
||||
|
||||
Redaction is a best-effort privacy layer, not a formal DLP engine. Application-specific free-form values may contain sensitive data that cannot be inferred safely. Review exports before sharing them outside the intended context.
|
||||
|
After Width: | Height: | Size: 279 KiB |
|
After Width: | Height: | Size: 260 KiB |
@@ -0,0 +1,33 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("workspace navigation and primary controls expose accessible state", async ({ page }) => {
|
||||
await page.goto("/#analysis");
|
||||
for (const workspace of ["Analysis", "Timeline", "Hunting", "Reports", "Settings"]) {
|
||||
await expect(page.getByRole("button", { name: workspace, exact: false }).first()).toBeEnabled();
|
||||
}
|
||||
await page.getByRole("button", { name: "Load demo", exact: true }).first().click();
|
||||
await page.getByRole("button", { name: "Timeline", exact: false }).click();
|
||||
await expect(page.getByRole("button", { name: "Timeline", exact: false }).first()).toHaveAttribute("aria-current", "page");
|
||||
await expect(page.getByRole("textbox", { name: "Search normalized evidence" })).toBeVisible();
|
||||
await expect(page.getByLabel("Filter by category")).toBeVisible();
|
||||
await expect(page.getByLabel("Filter by level")).toBeVisible();
|
||||
await expect(page.getByLabel("Filter by provider")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Previous evidence" })).toHaveAttribute("aria-label", "Previous evidence");
|
||||
await expect(page.getByRole("listitem").first()).toHaveAttribute("aria-setsize", /[0-9]+/);
|
||||
await page.getByRole("textbox", { name: "Search normalized evidence" }).focus();
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(page.locator(":focus")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Hunting", exact: false }).click();
|
||||
await expect(page.getByRole("textbox", { name: "Search hunting evidence" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Reports", exact: false }).click();
|
||||
await expect(page.getByRole("button", { name: "Export JSON", exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Settings", exact: false }).click();
|
||||
await expect(page.getByRole("group", { name: "Timestamp display" })).toBeVisible();
|
||||
|
||||
await page.setViewportSize({ width: 700, height: 900 });
|
||||
await page.getByRole("button", { name: "Timeline", exact: false }).click();
|
||||
await expect(page.getByLabel("Evidence time window")).toBeVisible();
|
||||
await expect(page.getByLabel("Hide informational noise")).toBeVisible();
|
||||
await expect(page.getByLabel("Focus incident")).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
const validUpload = { name: "sample.evtx", mimeType: "application/octet-stream", buffer: Buffer.from("synthetic") };
|
||||
|
||||
test("malformed successful analysis keeps the previous investigation", async ({ page }) => {
|
||||
await page.goto("/#analysis");
|
||||
await page.getByRole("button", { name: "Load demo", exact: true }).first().click();
|
||||
await expect(page.getByRole("region", { name: "Current investigation context" })).toContainText("bundled-demo");
|
||||
|
||||
await page.route("**/api/analyze", (route) => route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ source: "bad.evtx", events: [], incidents: [{ id: "broken" }] }),
|
||||
}));
|
||||
await page.locator('input[type="file"]').setInputFiles(validUpload);
|
||||
|
||||
await expect(page.getByRole("alert")).toContainText("Analysis returned an invalid response.");
|
||||
await expect(page.getByRole("region", { name: "Current investigation context" })).toContainText("bundled-demo");
|
||||
await page.getByRole("button", { name: "Timeline", exact: false }).click();
|
||||
await expect(page.getByRole("heading", { name: "Inspector", exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("structured parser errors have safe, useful wording", async ({ page }) => {
|
||||
await page.route("**/api/analyze", (route) => route.fulfill({
|
||||
status: 422,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ detail: { code: "no_readable_events", message: "No readable Windows events were found in this file." } }),
|
||||
}));
|
||||
await page.goto("/#analysis");
|
||||
await page.locator('input[type="file"]').setInputFiles(validUpload);
|
||||
|
||||
await expect(page.getByRole("alert")).toContainText("No readable Windows events were found in this file.");
|
||||
await expect(page.getByRole("alert")).not.toContainText("HTTP 422");
|
||||
});
|
||||
|
||||
test("rejecting an invalid file cancels an older pending analysis", async ({ page }) => {
|
||||
await page.goto("/#analysis");
|
||||
await page.getByRole("button", { name: "Load demo", exact: true }).first().click();
|
||||
await expect(page.getByRole("region", { name: "Current investigation context" })).toContainText("bundled-demo");
|
||||
|
||||
let routeStarted!: () => void;
|
||||
let releaseRoute!: () => void;
|
||||
const started = new Promise<void>((resolve) => { routeStarted = resolve; });
|
||||
const released = new Promise<void>((resolve) => { releaseRoute = resolve; });
|
||||
await page.route("**/api/analyze", async (route) => {
|
||||
routeStarted();
|
||||
await released;
|
||||
try {
|
||||
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ source: "late.evtx", events: [], incidents: [] }) });
|
||||
} catch {
|
||||
// The route can already be cancelled by the browser request abort.
|
||||
}
|
||||
});
|
||||
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles(validUpload);
|
||||
await started;
|
||||
await fileInput.setInputFiles({ name: "invalid.txt", mimeType: "text/plain", buffer: Buffer.from("invalid") });
|
||||
await expect(page.getByRole("alert")).toContainText("Choose a Windows .evtx file.");
|
||||
releaseRoute();
|
||||
await page.waitForTimeout(300);
|
||||
await expect(page.getByRole("region", { name: "Current investigation context" })).toContainText("bundled-demo");
|
||||
await expect(page.getByRole("alert")).toContainText("Choose a Windows .evtx file.");
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
const assets = [
|
||||
["favicon.svg", /image\/svg\+xml/i],
|
||||
["favicon.ico", /image\/(x-icon|vnd\.microsoft\.icon)/i],
|
||||
["apple-touch-icon.png", /image\/png/i],
|
||||
["eventlens-icon-192.png", /image\/png/i],
|
||||
["eventlens-icon-512.png", /image\/png/i],
|
||||
["site.webmanifest", /(manifest|json)/i],
|
||||
] as const;
|
||||
|
||||
test("canonical EventLens branding and icon package are locally served", async ({ page, request }) => {
|
||||
await page.goto("/#analysis");
|
||||
await expect(page).toHaveTitle("EventLens — Windows Incident Reconstruction");
|
||||
await expect(page.locator('meta[name="application-name"]')).toHaveAttribute("content", "EventLens");
|
||||
await expect(page.locator('link[rel="manifest"]')).toHaveAttribute("href", "/site.webmanifest");
|
||||
await expect(page.locator(".event-logo svg")).toBeVisible();
|
||||
|
||||
for (const [asset, contentType] of assets) {
|
||||
const response = await request.get(`/${asset}`);
|
||||
expect(response.status(), `${asset} should be served`).toBe(200);
|
||||
expect(response.headers()["content-type"] ?? "", `${asset} MIME type`).toMatch(contentType);
|
||||
}
|
||||
|
||||
for (const workspace of ["Analysis", "Timeline", "Hunting", "Reports", "Settings"]) {
|
||||
await expect(page.getByRole("button", { name: workspace, exact: false }).first().locator("svg")).toHaveCount(1);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("demo investigation workflow crosses Analysis, Timeline and Reports", async ({ page }) => {
|
||||
await page.goto("/#analysis");
|
||||
await expect(page.getByRole("heading", { name: "No event log loaded", exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Load demo", exact: true }).first().click();
|
||||
await expect(page.getByRole("region", { name: "Current investigation context" }).getByText("bundled-demo", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Incident windows", exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Analysis status", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Deterministic demo fixture", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Current source", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText("How to continue", { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByText("Service failure recorded", { exact: true }).click();
|
||||
await expect(page).toHaveURL(/#timeline$/);
|
||||
await expect(page.getByRole("heading", { name: "Event timeline", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Example Sync Service terminated unexpectedly", exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Focus active", { exact: true })).toBeVisible();
|
||||
|
||||
const search = page.getByRole("textbox", { name: "Search normalized evidence" });
|
||||
await search.fill("DNS name resolution");
|
||||
await expect(page.locator(".event-row")).toHaveCount(0);
|
||||
await search.fill("");
|
||||
await page.getByRole("button", { name: "Stop focus", exact: true }).click();
|
||||
await page.getByLabel("Filter by category").selectOption("network");
|
||||
await expect(page.locator(".event-row")).toHaveCount(1);
|
||||
await page.getByLabel("Filter by category").selectOption("");
|
||||
await page.getByRole("button", { name: /Next evidence/ }).click();
|
||||
await expect(page.getByText(/of [0-9]+/).first()).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Reports", exact: false }).click();
|
||||
await expect(page.getByRole("heading", { name: "Export report", exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Redaction preview", { exact: true })).toBeVisible();
|
||||
const jsonDownload = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "Export JSON", exact: true }).click();
|
||||
expect((await jsonDownload).suggestedFilename()).toMatch(/eventlens\.json$/);
|
||||
const markdownDownload = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "Export Markdown", exact: true }).click();
|
||||
expect((await markdownDownload).suggestedFilename()).toMatch(/eventlens\.md$/);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
const partialAnalysis = {
|
||||
source: "partial.evtx",
|
||||
events: [{ id: "1", timestamp: "2026-09-09T10:00:00Z", timestamp_source: "event", timestamp_valid: true, provider: "Synthetic", event_id: 1000, level: "error", channel: "Application", computer: "TEST-PC", category: "crash", summary: "Synthetic crash", fields: {} }],
|
||||
incidents: [{ id: "incident-1", rule_id: "windows.generic.attention", start: "2026-09-09T10:00:00Z", end: "2026-09-09T10:00:00Z", title: "Synthetic crash", category: "crash", severity: "warning", reasons: ["Parser retained only a partial result."], evidence_event_ids: ["1"], original_count: 1, compressed_count: 1 }],
|
||||
parse: { parser: "evtx-rs", status: "partial", parsed_events: 1, records_seen: 4, skipped_records: 2, issue_count: 1, truncated: true, truncation_reason: "event_limit_reached", duration_ms: 8, issues: [{ code: "event_limit_reached", record_id: 4, message: "later records were not analyzed", skipped_record: true }] },
|
||||
};
|
||||
|
||||
test("partial result is unmistakable and invalid extension is rejected", async ({ page }) => {
|
||||
await page.route("**/api/analyze", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(partialAnalysis) }));
|
||||
await page.goto("/#analysis");
|
||||
await expect(page.getByRole("heading", { name: "Analysis", exact: true })).toBeVisible();
|
||||
await page.locator('input[type="file"]').setInputFiles({ name: "partial.evtx", mimeType: "application/octet-stream", buffer: Buffer.from("synthetic") });
|
||||
await expect(page.getByRole("alert")).toContainText("Partial analysis");
|
||||
await expect(page.getByRole("alert")).toContainText("incident reconstruction may be incomplete");
|
||||
await expect(page.getByText("event_limit_reached", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Analysis status", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("2 skipped · 1 parser issue", { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Timeline", exact: false }).click();
|
||||
await expect(page.getByRole("alert")).toContainText("incident reconstruction may be incomplete");
|
||||
|
||||
await page.locator('input[type="file"]').setInputFiles({ name: "not-an-evtx.txt", mimeType: "text/plain", buffer: Buffer.from("nope") });
|
||||
await expect(page.getByRole("alert").filter({ hasText: "Choose a Windows .evtx file." })).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("capture deterministic v1.0.6 portfolio views", async ({ page }) => {
|
||||
await page.goto("/#analysis");
|
||||
await page.getByRole("button", { name: "Load demo", exact: true }).first().click();
|
||||
await expect(page.getByRole("heading", { name: "Incident windows", exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: "../docs/assets/eventlens-demo-overview.png", fullPage: true });
|
||||
|
||||
await page.getByText("Service failure recorded", { exact: true }).click();
|
||||
await expect(page.getByRole("heading", { name: "Event timeline", exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: "../docs/assets/eventlens-timeline-evidence.png", fullPage: true });
|
||||
|
||||
await page.getByRole("button", { name: "Stop focus", exact: true }).click();
|
||||
await page.getByRole("textbox", { name: "Search normalized evidence" }).fill("DNS name resolution");
|
||||
await expect(page.locator(".event-row")).toHaveCount(1);
|
||||
await page.screenshot({ path: "../docs/assets/eventlens-filter-search.png", fullPage: true });
|
||||
|
||||
await page.getByRole("button", { name: "Hunting", exact: false }).click();
|
||||
await expect(page.getByRole("heading", { name: "Hunting", exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: "../docs/assets/eventlens-hunting.png", fullPage: true });
|
||||
|
||||
await page.getByRole("button", { name: "Reports", exact: false }).click();
|
||||
await expect(page.getByRole("heading", { name: "Export report", exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: "../docs/assets/eventlens-redaction-export.png", fullPage: true });
|
||||
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click();
|
||||
await expect(page.getByRole("heading", { name: "Local data & privacy", exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: "../docs/assets/eventlens-settings.png", fullPage: true });
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
const fixture = process.env.EVENTLENS_EVTX_FIXTURE;
|
||||
|
||||
test("pinned real EVTX fixture survives the browser upload path", async ({ page }) => {
|
||||
test.skip(!fixture || !existsSync(fixture), "CI supplies the hash-verified public EVTX fixture");
|
||||
await page.goto("/#analysis");
|
||||
await page.locator('input[type="file"]').setInputFiles(fixture!);
|
||||
await expect(page.locator(".metric-card").first()).toContainText("7", { timeout: 30_000 });
|
||||
await expect(page.getByText("events parsed", { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Timeline", exact: false }).click();
|
||||
await page.getByLabel("Hide informational noise").uncheck();
|
||||
await expect(page.locator(".event-row").first()).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /4625/ })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Reports", exact: false }).click();
|
||||
await expect(page.getByRole("heading", { name: "Export report", exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: /Export JSON/ }).click();
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("core workspace actions remain usable across supported viewport widths", async ({ page }) => {
|
||||
await page.goto("/#analysis");
|
||||
await page.getByRole("button", { name: "Load demo", exact: true }).first().click();
|
||||
for (const viewport of [{ width: 360, height: 800 }, { width: 390, height: 844 }, { width: 768, height: 900 }, { width: 1024, height: 768 }, { width: 1366, height: 768 }, { width: 1440, height: 900 }, { width: 1920, height: 1080 }]) {
|
||||
await page.setViewportSize(viewport);
|
||||
await expect(page.getByRole("button", { name: "Open .evtx", exact: true }).first()).toBeVisible();
|
||||
for (const workspace of ["Analysis", "Timeline", "Hunting", "Reports", "Settings"]) {
|
||||
const button = page.getByRole("button", { name: workspace, exact: false }).first();
|
||||
await button.click();
|
||||
await expect(page.getByRole("heading", { name: workspace, exact: true })).toBeVisible();
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1);
|
||||
expect(overflow, `horizontal overflow in ${workspace} at ${viewport.width}px`).toBeTruthy();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("settings keeps session state and reports a persistence failure", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
const originalSetItem = Storage.prototype.setItem;
|
||||
Storage.prototype.setItem = function setItem(key: string, value: string) {
|
||||
if (key === "eventlens.preferences.v1") {
|
||||
throw new DOMException("Storage is unavailable", "QuotaExceededError");
|
||||
}
|
||||
return originalSetItem.call(this, key, value);
|
||||
};
|
||||
});
|
||||
|
||||
await page.goto("/#settings");
|
||||
|
||||
const compact = page.getByRole("radio", { name: /Compact/i });
|
||||
await compact.press("Space");
|
||||
|
||||
await expect(compact).toBeChecked();
|
||||
await expect(page.locator(".settings-save-feedback")).toHaveText("Preferences changed for this session only");
|
||||
await expect(page.getByText("Saved automatically in this browser", { exact: true })).toBeHidden();
|
||||
await expect(page.getByText("Preferences are available for this session only because browser storage is unavailable.")).toBeVisible();
|
||||
await expect(page.getByText("Not saved to browser storage")).toBeVisible();
|
||||
await expect(page.getByText("Interface preferences for this session")).toBeVisible();
|
||||
|
||||
await page.waitForTimeout(2_600);
|
||||
await expect(page.getByRole("status")).toHaveText("Preferences changed for this session only");
|
||||
await expect(page.getByText("Saved automatically in this browser", { exact: true })).toBeHidden();
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByRole("radio", { name: /Comfortable/i })).toBeChecked();
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("Settings communicates browser-local saving and resets accessible preference controls", async ({ page }) => {
|
||||
await page.goto("/#settings");
|
||||
|
||||
await expect(page.getByText("Saved automatically in this browser", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("group", { name: "Timestamp display" })).toBeVisible();
|
||||
await expect(page.getByRole("group", { name: "Interface density" })).toBeVisible();
|
||||
await expect(page.getByRole("switch", { name: "Export redaction by default" })).toBeChecked();
|
||||
|
||||
await page.locator("label.preference-option").filter({ hasText: "Local browser time" }).click();
|
||||
await expect(page.getByRole("status").filter({ hasText: "Preferences saved" })).toBeVisible();
|
||||
await page.getByRole("switch", { name: "Export redaction by default" }).press("Space");
|
||||
await page.getByLabel("Default noise mode").selectOption("all");
|
||||
await page.getByLabel("Default workspace").selectOption("hunting");
|
||||
await page.getByRole("radio", { name: /Compact/ }).press("Space");
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByRole("radio", { name: /Local browser time/ })).toBeChecked();
|
||||
await expect(page.getByRole("switch", { name: "Export redaction by default" })).not.toBeChecked();
|
||||
await expect(page.getByLabel("Default noise mode")).toHaveValue("all");
|
||||
await expect(page.getByLabel("Default workspace")).toHaveValue("hunting");
|
||||
await expect(page.getByRole("radio", { name: /Compact/ })).toBeChecked();
|
||||
|
||||
const storedKeys = await page.evaluate(() => Object.keys(localStorage));
|
||||
expect(storedKeys).toEqual(["eventlens.preferences.v1"]);
|
||||
await page.getByText("Technical details", { exact: true }).press("Enter");
|
||||
await expect(page.getByText("eventlens.preferences.v1", { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Reset all preferences" }).press("Enter");
|
||||
await expect(page.getByRole("status").filter({ hasText: "Preferences reset to defaults" })).toBeVisible();
|
||||
await expect(page.getByRole("radio", { name: /^UTC/ })).toBeChecked();
|
||||
await expect(page.getByRole("switch", { name: "Export redaction by default" })).toBeChecked();
|
||||
await expect(page.getByLabel("Default noise mode")).toHaveValue("attention");
|
||||
await expect(page.getByLabel("Default workspace")).toHaveValue("analysis");
|
||||
});
|
||||
|
||||
test("a browser-local default workspace applies only without a deep-link override", async ({ page }) => {
|
||||
await page.goto("/#settings");
|
||||
await page.getByLabel("Default workspace").selectOption("hunting");
|
||||
|
||||
await page.goto("/");
|
||||
await expect(page).toHaveURL(/#hunting$/);
|
||||
await expect(page.getByRole("heading", { name: "Hunting", exact: true })).toBeVisible();
|
||||
|
||||
await page.goto("/#timeline");
|
||||
await expect(page).toHaveURL(/#timeline$/);
|
||||
await expect(page.getByRole("heading", { name: "Timeline", exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("the active Settings tab remains visible in the compact workspace navigation", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/#settings");
|
||||
const settingsTab = page.getByRole("button", { name: "Settings", exact: true });
|
||||
await expect(settingsTab).toHaveAttribute("aria-current", "page");
|
||||
const box = await settingsTab.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
expect(box!.x).toBeGreaterThanOrEqual(0);
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(390);
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
function largeAnalysis(count: number) {
|
||||
return {
|
||||
source: "synthetic-large.evtx",
|
||||
events: Array.from({ length: count }, (_, index) => ({ id: String(index), timestamp: "2026-09-09T10:00:00Z", timestamp_source: "event", timestamp_valid: true, provider: "Synthetic Provider", event_id: index, level: "warning", channel: "Application", computer: "TEST-PC", category: "other", summary: `Synthetic event ${index}`, fields: { message: `Synthetic event ${index}` } })),
|
||||
incidents: [],
|
||||
};
|
||||
}
|
||||
|
||||
test("large timelines virtualize DOM rows and keep search usable", async ({ page }) => {
|
||||
const analysis = largeAnalysis(10_000);
|
||||
await page.route("**/api/demo", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(analysis) }));
|
||||
await page.goto("/#analysis");
|
||||
await page.getByRole("button", { name: "Load demo", exact: true }).first().click();
|
||||
await expect(page.getByText("10,000", { exact: false }).first()).toBeVisible();
|
||||
expect(await page.locator(".event-row").count()).toBeLessThan(80);
|
||||
await page.getByRole("button", { name: "Timeline", exact: false }).click();
|
||||
await page.getByRole("textbox", { name: "Search normalized evidence" }).fill("Synthetic event 9999");
|
||||
await expect(page.locator(".event-row")).toHaveCount(1);
|
||||
await expect(page.locator(".event-row").first()).toContainText("9999");
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("all workspaces are actionable and preserve the loaded analysis", async ({ page }) => {
|
||||
await page.goto("/#analysis");
|
||||
await page.getByRole("button", { name: "Load demo", exact: true }).first().click();
|
||||
|
||||
for (const workspace of ["Analysis", "Timeline", "Hunting", "Reports", "Settings"]) {
|
||||
const button = page.getByRole("button", { name: workspace, exact: false }).first();
|
||||
await expect(button).toBeEnabled();
|
||||
await button.click();
|
||||
await expect(button).toHaveAttribute("aria-current", "page");
|
||||
}
|
||||
|
||||
await expect(page.getByRole("region", { name: "Local preferences context" })).toBeVisible();
|
||||
await expect(page.getByRole("region", { name: "Current investigation context" })).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Timeline", exact: false }).click();
|
||||
await expect(page.getByRole("heading", { name: "Event timeline", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Inspector", exact: true })).toBeVisible();
|
||||
await page.goBack();
|
||||
await expect(page.getByRole("heading", { name: "Settings", exact: true })).toBeVisible();
|
||||
await page.goForward();
|
||||
await expect(page.getByRole("heading", { name: "Event timeline", exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("context and global actions keep an ordered, workspace-appropriate layout", async ({ page }) => {
|
||||
await page.goto("/#analysis");
|
||||
await page.getByRole("button", { name: "Load demo", exact: true }).first().click();
|
||||
const geometry = await page.evaluate(() => {
|
||||
const rect = (selector: string) => document.querySelector(selector)?.getBoundingClientRect();
|
||||
const topbar = rect(".event-topbar");
|
||||
const context = rect(".investigation-context");
|
||||
const actions = rect(".global-actions");
|
||||
return { topbar, context, actions };
|
||||
});
|
||||
expect(geometry.topbar?.bottom).toBeLessThanOrEqual((geometry.context?.top ?? 0) + 1);
|
||||
expect(geometry.context?.bottom).toBeLessThanOrEqual((geometry.actions?.top ?? 0) + 1);
|
||||
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click();
|
||||
await expect(page.getByRole("region", { name: "Local preferences context" })).toBeVisible();
|
||||
await expect(page.getByRole("region", { name: "Current investigation context" })).toHaveCount(0);
|
||||
await expect(page.getByRole("region", { name: "Analysis actions" })).toBeVisible();
|
||||
await expect(page.getByText("Source", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Hunting combines facets and opens a result in the inspector", async ({ page }) => {
|
||||
await page.goto("/#analysis");
|
||||
await page.getByRole("button", { name: "Load demo", exact: true }).first().click();
|
||||
await page.getByRole("button", { name: "Hunting", exact: false }).click();
|
||||
|
||||
await page.getByRole("textbox", { name: "Search hunting evidence" }).fill("DNS name resolution");
|
||||
await expect(page.getByRole("status")).toHaveText("1 of 6 matching events");
|
||||
await expect(page.getByRole("button", { name: /DNS name resolution timed out/ })).toBeVisible();
|
||||
await page.getByRole("button", { name: /DNS name resolution timed out/ }).click();
|
||||
await expect(page.getByRole("heading", { name: "Inspector", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "DNS name resolution timed out", exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Open in Timeline", exact: false }).click();
|
||||
await expect(page).toHaveURL(/#timeline$/);
|
||||
await expect(page.getByRole("heading", { name: "Event timeline", exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("Settings persist preferences without persisting event data", async ({ page }) => {
|
||||
await page.goto("/#settings");
|
||||
await page.getByRole("radio", { name: /Local browser time/ }).press("Space");
|
||||
await page.getByLabel("Default noise mode").selectOption("all");
|
||||
await page.getByLabel("Default workspace").selectOption("hunting");
|
||||
await page.getByRole("radio", { name: /Compact/ }).press("Space");
|
||||
await page.reload();
|
||||
|
||||
await expect(page.getByLabel("Local browser time", { exact: false })).toBeChecked();
|
||||
await expect(page.getByLabel("Default noise mode")).toHaveValue("all");
|
||||
await expect(page.getByLabel("Default workspace")).toHaveValue("hunting");
|
||||
await expect(page.getByLabel("Compact", { exact: false })).toBeChecked();
|
||||
const stored = await page.evaluate(() => JSON.stringify(localStorage));
|
||||
expect(stored).toContain("eventlens.preferences.v1");
|
||||
expect(stored).not.toContain("bundled-demo");
|
||||
expect(stored).not.toContain("Example Sync Service");
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import eslint from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import jsxA11y from "eslint-plugin-jsx-a11y";
|
||||
import react from "eslint-plugin-react";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist/**", "node_modules/**"] },
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
react.configs.flat.recommended,
|
||||
react.configs.flat["jsx-runtime"],
|
||||
jsxA11y.flatConfigs.recommended,
|
||||
{
|
||||
files: ["src/**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||
},
|
||||
plugins: { "react-hooks": reactHooks },
|
||||
settings: { react: { version: "detect" } },
|
||||
rules: {
|
||||
"jsx-a11y/label-has-associated-control": ["error", { depth: 4 }],
|
||||
"no-alert": "error",
|
||||
"no-eval": "error",
|
||||
"no-undef": "off",
|
||||
"no-unused-vars": "off",
|
||||
"react/no-danger": "error",
|
||||
"react/prop-types": "off",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"@typescript-eslint/no-explicit-any": "error",
|
||||
"@typescript-eslint/no-non-null-assertion": "warn",
|
||||
"@typescript-eslint/no-unused-vars": ["error", {
|
||||
argsIgnorePattern: "^_",
|
||||
caughtErrorsIgnorePattern: "^_",
|
||||
ignoreRestSiblings: true,
|
||||
varsIgnorePattern: "^_",
|
||||
}],
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Local-first Windows EVTX incident reconstruction with deterministic correlation, evidence timelines and privacy-aware exports." />
|
||||
<meta name="application-name" content="EventLens" />
|
||||
<meta name="theme-color" content="#07131f" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<title>EventLens — Windows Incident Reconstruction</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "eventlens-web",
|
||||
"version": "1.0.6",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"lint": "eslint src",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.5",
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"globals": "^17.12.0",
|
||||
"typescript": "^5.8.0",
|
||||
"typescript-eslint": "^8.70.1",
|
||||
"vite": "^6.0.0",
|
||||
"vitest": "^5.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
fullyParallel: false,
|
||||
forbidOnly: Boolean(process.env.CI),
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
reporter: process.env.CI ? "line" : "list",
|
||||
use: {
|
||||
baseURL: "http://127.0.0.1:4173",
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
},
|
||||
webServer: [
|
||||
{
|
||||
command: "python -m uv run --directory backend --locked --extra dev python -m uvicorn app.main:app --host 127.0.0.1 --port 8000",
|
||||
cwd: "..",
|
||||
url: "http://127.0.0.1:8000/health",
|
||||
reuseExistingServer: false,
|
||||
timeout: 30_000,
|
||||
},
|
||||
{
|
||||
command: "npm run dev -- --host 127.0.0.1 --port 4173",
|
||||
cwd: ".",
|
||||
url: "http://127.0.0.1:4173",
|
||||
reuseExistingServer: false,
|
||||
timeout: 30_000,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title">
|
||||
<title id="title">EventLens evidence lens</title>
|
||||
<rect width="64" height="64" rx="15" fill="#07131f"/>
|
||||
<circle cx="27" cy="27" r="16" fill="none" stroke="#65d9d0" stroke-width="5"/>
|
||||
<path d="M16 29h22" fill="none" stroke="#dff8f5" stroke-width="2.7" stroke-linecap="round"/>
|
||||
<circle cx="20" cy="29" r="2.4" fill="#65d9d0"/>
|
||||
<circle cx="27" cy="29" r="2.4" fill="#65d9d0"/>
|
||||
<circle cx="34" cy="29" r="2.4" fill="#65d9d0"/>
|
||||
<path d="m39 39 12 12" fill="none" stroke="#f4b860" stroke-width="6" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 636 B |
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "EventLens — Windows Incident Reconstruction",
|
||||
"short_name": "EventLens",
|
||||
"description": "Local-first Windows EVTX incident reconstruction with deterministic correlation and evidence timelines.",
|
||||
"start_url": "/#analysis",
|
||||
"display": "standalone",
|
||||
"background_color": "#07131f",
|
||||
"theme_color": "#07131f",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/eventlens-icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/eventlens-icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { type ChangeEvent, type DragEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { analysisToMarkdown, prepareExport } from "../export";
|
||||
import { filterEvents, uniqueProviders, type EventFilters } from "../filter";
|
||||
import type { Analysis, EventItem, Incident } from "../types";
|
||||
import { downloadText, exportStem } from "./format";
|
||||
import { DEFAULT_SETTINGS, EMPTY_FILTERS, EMPTY_HUNTING, ROW_HEIGHTS, SETTINGS_KEY, readSettings, type FilterState, type HuntingState, type InspectorTab, type UiSettings, type Workspace, workspaceFromHash } from "./model";
|
||||
import { isAnalysis } from "./validation";
|
||||
import { AppTopbar, GlobalActions, InvestigationContext, Statusbar } from "../components/shell";
|
||||
import { ErrorBanner, ParserIssueNotice, PartialAnalysisWarning } from "../components/states";
|
||||
import { AnalysisView } from "../workspaces/AnalysisView";
|
||||
import { HuntingView } from "../workspaces/HuntingView";
|
||||
import { ReportsView } from "../workspaces/ReportsView";
|
||||
import { SettingsView } from "../workspaces/SettingsView";
|
||||
import { TimelineView } from "../workspaces/TimelineView";
|
||||
|
||||
const API = (import.meta.env.VITE_API_BASE_URL ?? (import.meta.env.DEV ? "http://127.0.0.1:8000" : "")).replace(/\/$/, "");
|
||||
const APP_VERSION = import.meta.env.VITE_EVENTLENS_VERSION ?? "development";
|
||||
|
||||
function analysisErrorMessage(status: number, body: unknown): string {
|
||||
const detail = typeof body === "object" && body !== null && "detail" in body ? body.detail : null;
|
||||
const code = typeof detail === "object" && detail !== null && "code" in detail ? detail.code : null;
|
||||
if (code === "no_readable_events") return "No readable Windows events were found in this file.";
|
||||
if (code === "parser_rejected_input") return "The EVTX parser rejected the input. Check the file and retry.";
|
||||
if (code === "parse_capacity_exceeded") return "EventLens is busy analyzing another upload; retry shortly.";
|
||||
if (status === 400) return "Upload a Windows .evtx file.";
|
||||
if (status === 413) return "EVTX file exceeds the 50 MiB limit.";
|
||||
if (status >= 500) return "The local EventLens service is unavailable. Check the service and retry.";
|
||||
return `Analysis failed (HTTP ${status}).`;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [settings, setSettings] = useState<UiSettings>(readSettings);
|
||||
const [activeWorkspace, setActiveWorkspace] = useState<Workspace>(() => workspaceFromHash() ?? readSettings().defaultWorkspace);
|
||||
const [data, setData] = useState<Analysis | null>(null);
|
||||
const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
|
||||
const [activeIncidentId, setActiveIncidentId] = useState("");
|
||||
const [focusIncident, setFocusIncident] = useState(false);
|
||||
const [filters, setFilters] = useState<FilterState>(() => EMPTY_FILTERS(readSettings().defaultNoise === "attention"));
|
||||
const [hunting, setHunting] = useState<HuntingState>(EMPTY_HUNTING);
|
||||
const [redactExports, setRedactExports] = useState(() => readSettings().defaultRedaction);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [inspectorTab, setInspectorTab] = useState<InspectorTab>("details");
|
||||
const [settingsNotice, setSettingsNotice] = useState("");
|
||||
const [settingsPersistenceFailed, setSettingsPersistenceFailed] = useState(false);
|
||||
const [settingsStorageConfirmed, setSettingsStorageConfirmed] = useState(false);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const requestRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||
setSettingsPersistenceFailed(false);
|
||||
setSettingsStorageConfirmed(true);
|
||||
setSettingsNotice((notice) => {
|
||||
if (notice === "Saving preferences…") return "Preferences saved";
|
||||
if (notice === "Resetting preferences…") return "Preferences reset to defaults";
|
||||
return notice;
|
||||
});
|
||||
} catch {
|
||||
setSettingsPersistenceFailed(true);
|
||||
setSettingsStorageConfirmed(false);
|
||||
setSettingsNotice("Preferences changed for this session only");
|
||||
}
|
||||
}, [settings]);
|
||||
useEffect(() => {
|
||||
if (!settingsNotice) return;
|
||||
const timeout = window.setTimeout(() => setSettingsNotice(""), 2_500);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [settingsNotice]);
|
||||
|
||||
useEffect(() => {
|
||||
const onHashChange = () => setActiveWorkspace(workspaceFromHash() ?? settings.defaultWorkspace);
|
||||
window.addEventListener("hashchange", onHashChange);
|
||||
if (!workspaceFromHash()) window.history.replaceState(null, "", `#${activeWorkspace}`);
|
||||
return () => window.removeEventListener("hashchange", onHashChange);
|
||||
}, [activeWorkspace, settings.defaultWorkspace]);
|
||||
|
||||
const navigate = (workspace: Workspace) => {
|
||||
if (workspace === activeWorkspace && window.location.hash === `#${workspace}`) return;
|
||||
window.location.hash = workspace;
|
||||
setActiveWorkspace(workspace);
|
||||
};
|
||||
|
||||
const activeIncident = useMemo(() => data?.incidents.find((incident) => incident.id === activeIncidentId) ?? null, [data, activeIncidentId]);
|
||||
const selected = useMemo(() => data?.events.find((event) => event.id === selectedEventId) ?? null, [data, selectedEventId]);
|
||||
const evidenceIds = useMemo(() => focusIncident && activeIncident ? new Set(activeIncident.evidence_event_ids) : null, [focusIncident, activeIncident]);
|
||||
const providers = useMemo(() => uniqueProviders(data?.events ?? []), [data]);
|
||||
const visibleEvents = useMemo(() => {
|
||||
const eventFilters: EventFilters = {
|
||||
search: filters.search,
|
||||
category: filters.category,
|
||||
level: filters.level,
|
||||
provider: filters.provider,
|
||||
noiseMode: filters.attentionOnly ? "attention" : "all",
|
||||
evidenceIds,
|
||||
windowMinutes: filters.windowMinutes,
|
||||
anchorTimestamp: selected?.timestamp ?? activeIncident?.start ?? null,
|
||||
};
|
||||
return filterEvents(data?.events ?? [], eventFilters);
|
||||
}, [data, filters, evidenceIds, selected, activeIncident]);
|
||||
const incidentEventIds = useMemo(() => new Set(data?.incidents.flatMap((incident) => incident.evidence_event_ids) ?? []), [data]);
|
||||
const huntingResults = useMemo(() => {
|
||||
const filtered = filterEvents(data?.events ?? [], { search: hunting.search, category: hunting.category, level: hunting.level, provider: hunting.provider, noiseMode: "all", windowMinutes: hunting.windowMinutes, anchorTimestamp: selected?.timestamp ?? activeIncident?.start ?? null });
|
||||
return filtered.filter((event) => (!hunting.eventId || String(event.event_id ?? "") === hunting.eventId) && (!hunting.computer || (event.computer ?? "").toLowerCase().includes(hunting.computer.toLowerCase())) && (!hunting.incidentOnly || incidentEventIds.has(event.id)));
|
||||
}, [data, hunting, selected, activeIncident, incidentEventIds]);
|
||||
const redactionPreview = useMemo(() => data && redactExports ? prepareExport(data, true).redaction : null, [data, redactExports]);
|
||||
const rowHeight = ROW_HEIGHTS[settings.density];
|
||||
const contextVisible = activeWorkspace === "timeline" ? visibleEvents.length : activeWorkspace === "hunting" ? huntingResults.length : undefined;
|
||||
|
||||
function updateSettings(patch: Partial<UiSettings>) {
|
||||
setSettingsStorageConfirmed(false);
|
||||
setSettings((current) => ({ ...current, ...patch }));
|
||||
setSettingsNotice("Saving preferences…");
|
||||
if (patch.defaultNoise) setFilters((current) => ({ ...current, attentionOnly: patch.defaultNoise === "attention" }));
|
||||
if (typeof patch.defaultRedaction === "boolean") setRedactExports(patch.defaultRedaction);
|
||||
}
|
||||
|
||||
function resetSettings() {
|
||||
setSettingsStorageConfirmed(false);
|
||||
setSettings({ ...DEFAULT_SETTINGS });
|
||||
setFilters(EMPTY_FILTERS(true));
|
||||
setRedactExports(true);
|
||||
setSettingsNotice("Resetting preferences…");
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
setFilters(EMPTY_FILTERS(settings.defaultNoise === "attention"));
|
||||
setFocusIncident(false);
|
||||
}
|
||||
|
||||
function resetHunting() { setHunting(EMPTY_HUNTING()); }
|
||||
|
||||
function resetView(next: Analysis) {
|
||||
setData(next);
|
||||
setSelectedEventId(next.events[0]?.id ?? null);
|
||||
setActiveIncidentId(next.incidents[0]?.id ?? "");
|
||||
setFocusIncident(false);
|
||||
setFilters(EMPTY_FILTERS(settings.defaultNoise === "attention"));
|
||||
resetHunting();
|
||||
setInspectorTab("details");
|
||||
}
|
||||
|
||||
async function loadDemo() {
|
||||
requestRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
requestRef.current = controller;
|
||||
setLoading(true); setError("");
|
||||
try {
|
||||
const response = await fetch(`${API}/api/demo`, { cache: "no-store", signal: controller.signal });
|
||||
const body: unknown = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error("Demo request failed");
|
||||
if (!isAnalysis(body)) throw new Error("Demo returned an invalid response");
|
||||
if (requestRef.current !== controller || controller.signal.aborted) return;
|
||||
resetView(body);
|
||||
} catch (reason) {
|
||||
if (requestRef.current === controller && !controller.signal.aborted) setError(reason instanceof Error && reason.message === "Demo returned an invalid response" ? reason.message : "The local EventLens service is unavailable. Check the service and retry.");
|
||||
} finally { if (requestRef.current === controller) { requestRef.current = null; setLoading(false); } }
|
||||
}
|
||||
|
||||
async function analyzeFile(file: File) {
|
||||
requestRef.current?.abort();
|
||||
requestRef.current = null;
|
||||
if (!file.name.toLowerCase().endsWith(".evtx")) { setLoading(false); setError("Choose a Windows .evtx file."); return; }
|
||||
const controller = new AbortController();
|
||||
requestRef.current = controller;
|
||||
setLoading(true); setError("");
|
||||
const form = new FormData(); form.append("file", file);
|
||||
try {
|
||||
const response = await fetch(`${API}/api/analyze`, { method: "POST", body: form, cache: "no-store", signal: controller.signal });
|
||||
const body: unknown = await response.json().catch(() => null);
|
||||
if (requestRef.current !== controller || controller.signal.aborted) return;
|
||||
if (!response.ok) { setError(analysisErrorMessage(response.status, body)); return; }
|
||||
if (!isAnalysis(body)) throw new Error("Analysis returned an invalid response.");
|
||||
resetView(body);
|
||||
} catch (reason) {
|
||||
if (requestRef.current === controller && !controller.signal.aborted) setError(reason instanceof Error && reason.message === "Analysis returned an invalid response." ? reason.message : "The local EventLens service is unavailable. Check the service and retry.");
|
||||
} finally { if (requestRef.current === controller) { requestRef.current = null; setLoading(false); } }
|
||||
}
|
||||
|
||||
async function upload(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) await analyzeFile(file);
|
||||
event.target.value = "";
|
||||
}
|
||||
|
||||
async function drop(event: DragEvent<HTMLElement>) {
|
||||
event.preventDefault(); setDragging(false);
|
||||
const file = event.dataTransfer.files?.[0];
|
||||
if (file) await analyzeFile(file);
|
||||
}
|
||||
|
||||
function selectIncident(id: string, openTimeline = false) {
|
||||
const incident = data?.incidents.find((item) => item.id === id);
|
||||
if (!incident) { setActiveIncidentId(""); return; }
|
||||
setActiveIncidentId(id);
|
||||
setSelectedEventId(incident.evidence_event_ids[0] ?? null);
|
||||
setInspectorTab("details");
|
||||
if (openTimeline) { setFocusIncident(true); navigate("timeline"); }
|
||||
}
|
||||
|
||||
function selectEvent(event: EventItem) {
|
||||
setSelectedEventId(event.id);
|
||||
const incident = data?.incidents.find((item) => item.evidence_event_ids.includes(event.id));
|
||||
if (incident) setActiveIncidentId(incident.id);
|
||||
}
|
||||
|
||||
function moveSelection(delta: number, events: EventItem[]) {
|
||||
if (!events.length) return;
|
||||
const index = selected ? events.findIndex((event) => event.id === selected.id) : -1;
|
||||
const next = index < 0 ? (delta > 0 ? 0 : events.length - 1) : Math.min(events.length - 1, Math.max(0, index + delta));
|
||||
selectEvent(events[next]);
|
||||
}
|
||||
|
||||
function exportJson() {
|
||||
if (!data) return;
|
||||
const prepared = prepareExport(data, redactExports);
|
||||
downloadText(`${exportStem(data.source)}-eventlens.json`, JSON.stringify(prepared.analysis, null, 2), "application/json");
|
||||
}
|
||||
|
||||
function exportMarkdown() {
|
||||
if (!data) return;
|
||||
downloadText(`${exportStem(data.source)}-eventlens.md`, analysisToMarkdown(data, redactExports), "text/markdown");
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const scopedEvents = activeWorkspace === "hunting" ? huntingResults : visibleEvents;
|
||||
if (!selectedEventId || !scopedEvents.some((event) => event.id === selectedEventId)) setSelectedEventId(scopedEvents[0]?.id ?? null);
|
||||
}, [activeWorkspace, huntingResults, visibleEvents, selectedEventId]);
|
||||
|
||||
return <main className={`event-app-shell density-${settings.density}`} onDragOver={(event) => { event.preventDefault(); setDragging(true); }} onDragLeave={() => setDragging(false)} onDrop={drop}>
|
||||
<AppTopbar activeWorkspace={activeWorkspace} onNavigate={navigate} version={APP_VERSION} />
|
||||
<section className="event-main" aria-label={`${activeWorkspace} workspace`}>
|
||||
<InvestigationContext data={data} activeIncident={activeIncident} visibleCount={contextVisible} workspace={activeWorkspace} />
|
||||
<GlobalActions loading={loading} dragging={dragging} onOpen={() => fileInput.current?.click()} onDemo={() => void loadDemo()} workspace={activeWorkspace} />
|
||||
<input className="hidden-input" aria-label="Choose EVTX file" ref={fileInput} type="file" accept=".evtx" onChange={upload} />
|
||||
{loading ? <div className="loading-banner" role="status" aria-live="polite"><span className="loading-spinner" aria-hidden="true" />Analyzing event log… The application shell remains available.</div> : null}
|
||||
{error ? <ErrorBanner message={error} onDemo={() => void loadDemo()} /> : null}
|
||||
{data?.parse?.status === "partial" ? <PartialAnalysisWarning parse={data.parse} /> : null}
|
||||
{data?.parse && data.parse.status !== "partial" && data.parse.issues.length ? <ParserIssueNotice parse={data.parse} /> : null}
|
||||
<div className="workspace-content">
|
||||
{activeWorkspace === "analysis" ? <AnalysisView data={data} timestampDisplay={settings.timestampDisplay} activeIncidentId={activeIncidentId} onOpen={(incident: Incident) => selectIncident(incident.id, true)} onOpenFile={() => fileInput.current?.click()} onDemo={() => void loadDemo()} /> : null}
|
||||
{activeWorkspace === "timeline" ? <TimelineView data={data} filters={filters} providers={providers} visibleEvents={visibleEvents} selected={selected} activeIncident={activeIncident} activeIncidentId={activeIncidentId} focusIncident={focusIncident} timestampDisplay={settings.timestampDisplay} rowHeight={rowHeight} inspectorTab={inspectorTab} onFilterChange={(patch) => setFilters((current) => ({ ...current, ...patch }))} onResetFilters={resetFilters} onFocusChange={setFocusIncident} onSelectIncident={selectIncident} onSelectEvent={selectEvent} onMove={(delta) => moveSelection(delta, visibleEvents)} onTab={setInspectorTab} onOpenFile={() => fileInput.current?.click()} onDemo={() => void loadDemo()} /> : null}
|
||||
{activeWorkspace === "hunting" ? <HuntingView data={data} state={hunting} providers={providers} results={huntingResults} selected={selected} activeIncident={activeIncident} timestampDisplay={settings.timestampDisplay} inspectorTab={inspectorTab} incidentEventIds={incidentEventIds} onChange={(patch) => setHunting((current) => ({ ...current, ...patch }))} onReset={resetHunting} onSelectEvent={selectEvent} onMove={(delta) => moveSelection(delta, huntingResults)} onTab={setInspectorTab} onOpenTimeline={() => navigate("timeline")} onOpenFile={() => fileInput.current?.click()} onDemo={() => void loadDemo()} /> : null}
|
||||
{activeWorkspace === "reports" ? <ReportsView data={data} redactExports={redactExports} redactionPreview={redactionPreview} onRedactionChange={setRedactExports} onExportJson={exportJson} onExportMarkdown={exportMarkdown} timestampDisplay={settings.timestampDisplay} onOpenFile={() => fileInput.current?.click()} onDemo={() => void loadDemo()} /> : null}
|
||||
{activeWorkspace === "settings" ? <SettingsView settings={settings} saveNotice={settingsPersistenceFailed ? "Preferences changed for this session only" : settingsNotice} storageFailed={settingsPersistenceFailed} storageConfirmed={settingsStorageConfirmed} onChange={updateSettings} onReset={resetSettings} /> : null}
|
||||
</div>
|
||||
<Statusbar data={data} />
|
||||
</section>
|
||||
</main>;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { Analysis } from "../types";
|
||||
import type { TimestampDisplay } from "./model";
|
||||
|
||||
export function sourceLabel(source: string): string {
|
||||
return source.split(/[\\/]/).pop() || source;
|
||||
}
|
||||
|
||||
export function safeDate(timestamp: string | null): Date | null {
|
||||
if (!timestamp) return null;
|
||||
const date = new Date(timestamp);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
export function formatClock(timestamp: string | null, display: TimestampDisplay): string {
|
||||
const date = safeDate(timestamp);
|
||||
if (!date) return "Unknown time";
|
||||
return display === "utc"
|
||||
? `${date.toISOString().slice(11, 16)} UTC`
|
||||
: date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
export function formatDate(timestamp: string | null, display: TimestampDisplay): string {
|
||||
const date = safeDate(timestamp);
|
||||
if (!date) return "Unknown date";
|
||||
return display === "utc"
|
||||
? date.toISOString().slice(0, 10)
|
||||
: date.toLocaleDateString([], { year: "numeric", month: "2-digit", day: "2-digit" });
|
||||
}
|
||||
|
||||
export function formatDateTime(timestamp: string | null, display: TimestampDisplay): string {
|
||||
const date = safeDate(timestamp);
|
||||
if (!date) return "Unknown — timestamp is not reliable";
|
||||
return display === "utc"
|
||||
? `${date.toISOString().replace("T", " ").replace("Z", "")} (UTC)`
|
||||
: date.toLocaleString([], { dateStyle: "medium", timeStyle: "medium" });
|
||||
}
|
||||
|
||||
export function formatRange(start: string | null, end: string | null, display: TimestampDisplay): string {
|
||||
if (!start) return "Unknown time";
|
||||
return `${formatDate(start, display)} · ${formatClock(start, display)} – ${end ? formatClock(end, display) : "unknown"}`;
|
||||
}
|
||||
|
||||
export function levelLabel(level: string): string {
|
||||
return level === "error" || level === "critical" ? "High" : level === "warning" ? "Medium" : level === "information" ? "Info" : level;
|
||||
}
|
||||
|
||||
export function statusLabel(data: Analysis | null): string {
|
||||
if (!data) return "No analysis loaded";
|
||||
if (!data.parse) return "Complete";
|
||||
return data.parse.status === "complete" ? "Complete" : data.parse.status === "partial" ? "Partial" : "Failed";
|
||||
}
|
||||
|
||||
export function downloadText(filename: string, content: string, type: string): void {
|
||||
const blob = new Blob([content], { type });
|
||||
const href = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = href;
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(href), 0);
|
||||
}
|
||||
|
||||
export function exportStem(source: string): string {
|
||||
const base = sourceLabel(source).replace(/\.evtx$/i, "");
|
||||
return base.replace(/[^A-Za-z0-9._-]+/g, "_").slice(0, 80) || "eventlens";
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
export const CATEGORIES = ["system", "service", "network", "authentication", "application", "crash", "other"];
|
||||
export const LEVELS = ["critical", "error", "warning", "information", "verbose"];
|
||||
export const WINDOWS = [0, 1, 5, 15, 60];
|
||||
export const WORKSPACES = ["analysis", "timeline", "hunting", "reports", "settings"] as const;
|
||||
export const ROW_HEIGHTS = { comfortable: 58, compact: 48 } as const;
|
||||
export const OVERSCAN = 8;
|
||||
export const SETTINGS_KEY = "eventlens.preferences.v1";
|
||||
|
||||
export type Workspace = (typeof WORKSPACES)[number];
|
||||
export type TimestampDisplay = "utc" | "local";
|
||||
export type Density = "comfortable" | "compact";
|
||||
|
||||
export type UiSettings = {
|
||||
timestampDisplay: TimestampDisplay;
|
||||
defaultNoise: "attention" | "all";
|
||||
defaultRedaction: boolean;
|
||||
density: Density;
|
||||
defaultWorkspace: Workspace;
|
||||
};
|
||||
|
||||
export type FilterState = {
|
||||
search: string;
|
||||
category: string;
|
||||
level: string;
|
||||
provider: string;
|
||||
windowMinutes: number;
|
||||
attentionOnly: boolean;
|
||||
};
|
||||
|
||||
export type HuntingState = FilterState & {
|
||||
eventId: string;
|
||||
computer: string;
|
||||
incidentOnly: boolean;
|
||||
};
|
||||
|
||||
export type InspectorTab = "details" | "raw" | "related" | "insights";
|
||||
|
||||
export const DEFAULT_SETTINGS: UiSettings = {
|
||||
timestampDisplay: "utc",
|
||||
defaultNoise: "attention",
|
||||
defaultRedaction: true,
|
||||
density: "comfortable",
|
||||
defaultWorkspace: "analysis",
|
||||
};
|
||||
|
||||
export const EMPTY_FILTERS = (attentionOnly: boolean): FilterState => ({
|
||||
search: "",
|
||||
category: "",
|
||||
level: "",
|
||||
provider: "",
|
||||
windowMinutes: 0,
|
||||
attentionOnly,
|
||||
});
|
||||
|
||||
export const EMPTY_HUNTING = (): HuntingState => ({
|
||||
...EMPTY_FILTERS(false),
|
||||
eventId: "",
|
||||
computer: "",
|
||||
incidentOnly: false,
|
||||
});
|
||||
|
||||
export function readSettings(): UiSettings {
|
||||
if (typeof window === "undefined") return { ...DEFAULT_SETTINGS };
|
||||
try {
|
||||
const parsed = JSON.parse(window.localStorage.getItem(SETTINGS_KEY) ?? "null") as Partial<UiSettings> | null;
|
||||
if (!parsed || typeof parsed !== "object") return { ...DEFAULT_SETTINGS };
|
||||
return {
|
||||
timestampDisplay: parsed.timestampDisplay === "local" ? "local" : "utc",
|
||||
defaultNoise: parsed.defaultNoise === "all" ? "all" : "attention",
|
||||
defaultRedaction: typeof parsed.defaultRedaction === "boolean" ? parsed.defaultRedaction : true,
|
||||
density: parsed.density === "compact" ? "compact" : "comfortable",
|
||||
defaultWorkspace: WORKSPACES.includes(parsed.defaultWorkspace as Workspace)
|
||||
? parsed.defaultWorkspace as Workspace
|
||||
: "analysis",
|
||||
};
|
||||
} catch {
|
||||
return { ...DEFAULT_SETTINGS };
|
||||
}
|
||||
}
|
||||
|
||||
export function workspaceFromHash(hash = typeof window === "undefined" ? "" : window.location.hash): Workspace | null {
|
||||
const value = hash.replace(/^#/, "");
|
||||
return WORKSPACES.includes(value as Workspace) ? value as Workspace : null;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isAnalysis } from "./validation";
|
||||
|
||||
const valid = {
|
||||
source: "demo.evtx",
|
||||
events: [{
|
||||
id: "event-1",
|
||||
timestamp: "2026-09-22T08:00:00Z",
|
||||
timestamp_source: "event",
|
||||
timestamp_valid: true,
|
||||
provider: "Provider",
|
||||
event_id: 1000,
|
||||
level: "Error",
|
||||
category: "crash",
|
||||
summary: "Example event",
|
||||
fields: { process: "example.exe" },
|
||||
}],
|
||||
incidents: [{
|
||||
id: "incident-1",
|
||||
rule_id: "crash.sequence",
|
||||
start: "2026-09-22T08:00:00Z",
|
||||
end: "2026-09-22T08:00:00Z",
|
||||
title: "Example incident",
|
||||
category: "crash",
|
||||
severity: "high",
|
||||
reasons: ["Observed crash evidence."],
|
||||
evidence_event_ids: ["event-1"],
|
||||
original_count: 1,
|
||||
compressed_count: 1,
|
||||
}],
|
||||
parse: {
|
||||
parser: "evtx",
|
||||
status: "complete",
|
||||
parsed_events: 1,
|
||||
records_seen: 1,
|
||||
skipped_records: 0,
|
||||
issue_count: 0,
|
||||
truncated: false,
|
||||
truncation_reason: null,
|
||||
duration_ms: 5,
|
||||
issues: [],
|
||||
},
|
||||
};
|
||||
|
||||
describe("isAnalysis", () => {
|
||||
it("accepts the normalized analysis contract", () => {
|
||||
expect(isAnalysis(valid)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a successful-looking object without event arrays", () => {
|
||||
expect(isAnalysis({ source: "demo.evtx", incidents: [] })).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects malformed nested evidence instead of trusting a 2xx payload", () => {
|
||||
expect(isAnalysis({ ...valid, events: [{ ...valid.events[0], fields: { process: 42 } }] })).toBe(false);
|
||||
expect(isAnalysis({ ...valid, parse: { ...valid.parse, status: "unknown" } })).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects missing evidence references and duplicate event IDs", () => {
|
||||
expect(isAnalysis({ ...valid, incidents: [{ ...valid.incidents[0], evidence_event_ids: ["missing"] }] })).toBe(false);
|
||||
expect(isAnalysis({ ...valid, events: [...valid.events, valid.events[0]], parse: { ...valid.parse, parsed_events: 2 } })).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects invalid timestamps and contradictory timestamp metadata", () => {
|
||||
expect(isAnalysis({ ...valid, events: [{ ...valid.events[0], timestamp: "not-a-date" }] })).toBe(false);
|
||||
expect(isAnalysis({ ...valid, events: [{ ...valid.events[0], timestamp: null, timestamp_valid: true }] })).toBe(false);
|
||||
expect(isAnalysis({ ...valid, events: [{ ...valid.events[0], timestamp_source: "invalid", timestamp_valid: true }] })).toBe(false);
|
||||
expect(isAnalysis({ ...valid, incidents: [{ ...valid.incidents[0], start: "not-a-date" }] })).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects failed or inconsistent successful parse responses", () => {
|
||||
expect(isAnalysis({ ...valid, parse: { ...valid.parse, status: "failed" } })).toBe(false);
|
||||
expect(isAnalysis({ ...valid, parse: { ...valid.parse, parsed_events: 2 } })).toBe(false);
|
||||
expect(isAnalysis({ ...valid, parse: { ...valid.parse, issue_count: 1 } })).toBe(false);
|
||||
expect(isAnalysis({ ...valid, incidents: [{ ...valid.incidents[0], original_count: 1.5 }] })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { Analysis, EventItem, Incident, ParseIssue, ParseStats } from "../types";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isTimestamp(value: unknown): value is string | null {
|
||||
return value === null || (typeof value === "string" && /(?:Z|[+-]\d{2}:\d{2})$/.test(value) && Number.isFinite(Date.parse(value)));
|
||||
}
|
||||
|
||||
function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function isNonnegativeInteger(value: unknown): value is number {
|
||||
return isFiniteNumber(value) && Number.isInteger(value) && value >= 0;
|
||||
}
|
||||
|
||||
function isStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
||||
}
|
||||
|
||||
function isEventItem(value: unknown): value is EventItem {
|
||||
if (!isRecord(value)) return false;
|
||||
if (typeof value.id !== "string" || !isTimestamp(value.timestamp)) return false;
|
||||
if (typeof value.provider !== "string" || !(value.event_id === null || (isNonnegativeInteger(value.event_id) && value.event_id <= 4_294_967_295))) return false;
|
||||
if (typeof value.level !== "string" || typeof value.category !== "string" || typeof value.summary !== "string") return false;
|
||||
if (value.channel !== undefined && typeof value.channel !== "string") return false;
|
||||
if (value.computer !== undefined && typeof value.computer !== "string") return false;
|
||||
if (value.fields !== undefined) {
|
||||
if (!isRecord(value.fields) || !Object.values(value.fields).every((item) => typeof item === "string")) return false;
|
||||
}
|
||||
if (typeof value.timestamp_valid !== "boolean" || !["event", "record", "missing", "invalid"].includes(String(value.timestamp_source))) return false;
|
||||
if (value.timestamp === null && value.timestamp_valid) return false;
|
||||
if (["event", "record"].includes(String(value.timestamp_source)) !== value.timestamp_valid) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function isIncident(value: unknown): value is Incident {
|
||||
if (!isRecord(value)) return false;
|
||||
return typeof value.id === "string"
|
||||
&& typeof value.rule_id === "string"
|
||||
&& isTimestamp(value.start)
|
||||
&& isTimestamp(value.end)
|
||||
&& typeof value.title === "string"
|
||||
&& typeof value.category === "string"
|
||||
&& typeof value.severity === "string"
|
||||
&& isStringArray(value.reasons)
|
||||
&& isStringArray(value.evidence_event_ids)
|
||||
&& isNonnegativeInteger(value.original_count)
|
||||
&& value.original_count > 0
|
||||
&& isNonnegativeInteger(value.compressed_count)
|
||||
&& value.compressed_count > 0;
|
||||
}
|
||||
|
||||
function isParseIssue(value: unknown): value is ParseIssue {
|
||||
if (!isRecord(value) || typeof value.code !== "string" || typeof value.message !== "string") return false;
|
||||
if (value.record_id !== undefined && value.record_id !== null && !isNonnegativeInteger(value.record_id)) return false;
|
||||
if (value.skipped_record !== undefined && typeof value.skipped_record !== "boolean") return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function isParseStats(value: unknown): value is ParseStats {
|
||||
if (!isRecord(value)) return false;
|
||||
if (typeof value.parser !== "string" || !["complete", "partial"].includes(String(value.status))) return false;
|
||||
for (const key of ["parsed_events", "records_seen", "skipped_records", "issue_count", "duration_ms"] as const) {
|
||||
if (!isNonnegativeInteger(value[key])) return false;
|
||||
}
|
||||
if (typeof value.truncated !== "boolean") return false;
|
||||
if (value.truncation_reason !== undefined && value.truncation_reason !== null && typeof value.truncation_reason !== "string") return false;
|
||||
return Array.isArray(value.issues) && value.issues.every(isParseIssue) && value.issue_count === value.issues.length;
|
||||
}
|
||||
|
||||
export function isAnalysis(value: unknown): value is Analysis {
|
||||
if (!isRecord(value) || typeof value.source !== "string") return false;
|
||||
if (!Array.isArray(value.events) || !value.events.every(isEventItem)) return false;
|
||||
if (!Array.isArray(value.incidents) || !value.incidents.every(isIncident)) return false;
|
||||
const eventIds = new Set(value.events.map((event: EventItem) => event.id));
|
||||
if (eventIds.size !== value.events.length) return false;
|
||||
if (!value.incidents.every((incident: Incident) => incident.evidence_event_ids.every((id) => eventIds.has(id)))) return false;
|
||||
return value.parse === undefined || value.parse === null || (isParseStats(value.parse) && value.parse.parsed_events === value.events.length);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
export type IconName =
|
||||
| "analysis"
|
||||
| "timeline"
|
||||
| "hunting"
|
||||
| "reports"
|
||||
| "settings"
|
||||
| "file"
|
||||
| "upload"
|
||||
| "demo"
|
||||
| "search"
|
||||
| "clock"
|
||||
| "warning"
|
||||
| "arrow-left"
|
||||
| "arrow-right"
|
||||
| "reset"
|
||||
| "info"
|
||||
| "export"
|
||||
| "check"
|
||||
| "privacy";
|
||||
|
||||
type IconProps = SVGProps<SVGSVGElement> & { name: IconName; title?: string };
|
||||
|
||||
const ICON_PROPS = { fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round" as const, strokeLinejoin: "round" as const };
|
||||
|
||||
export function Icon({ name, title, ...props }: IconProps) {
|
||||
const content = {
|
||||
analysis: <><path {...ICON_PROPS} d="M4 5h16v14H4z" /><path {...ICON_PROPS} d="M7 15h2m3-4h2m3-3h0" /><circle cx="8" cy="15" r="1" fill="currentColor" /><circle cx="13" cy="11" r="1" fill="currentColor" /><circle cx="18" cy="8" r="1" fill="currentColor" /></>,
|
||||
timeline: <><path {...ICON_PROPS} d="M5 5v14M12 5v14M19 5v14" /><circle cx="5" cy="9" r="2" fill="currentColor" /><circle cx="12" cy="15" r="2" fill="currentColor" /><circle cx="19" cy="7" r="2" fill="currentColor" /></>,
|
||||
hunting: <><circle {...ICON_PROPS} cx="10.5" cy="10.5" r="5.5" /><path {...ICON_PROPS} d="m15 15 5 5M4 4l2 2m12 12 2 2" /></>,
|
||||
reports: <><path {...ICON_PROPS} d="M6 3h8l4 4v14H6z" /><path {...ICON_PROPS} d="M14 3v5h5M9 12h6m-6 4h6" /></>,
|
||||
settings: <><path {...ICON_PROPS} d="M5 7h14M5 12h14M5 17h14" /><circle {...ICON_PROPS} cx="9" cy="7" r="2" /><circle {...ICON_PROPS} cx="15" cy="12" r="2" /><circle {...ICON_PROPS} cx="11" cy="17" r="2" /></>,
|
||||
file: <><path {...ICON_PROPS} d="M6 3h8l4 4v14H6z" /><path {...ICON_PROPS} d="M14 3v5h5" /></>,
|
||||
upload: <><path {...ICON_PROPS} d="M12 16V4m0 0L8 8m4-4 4 4M5 20h14" /></>,
|
||||
demo: <><path {...ICON_PROPS} d="M12 3 20 8v8l-8 5-8-5V8z" /><path {...ICON_PROPS} d="m10 9 5 3-5 3z" fill="currentColor" stroke="none" /></>,
|
||||
search: <><circle {...ICON_PROPS} cx="10.5" cy="10.5" r="6" /><path {...ICON_PROPS} d="m15 15 5 5" /></>,
|
||||
clock: <><circle {...ICON_PROPS} cx="12" cy="12" r="8" /><path {...ICON_PROPS} d="M12 7v5l3 2" /></>,
|
||||
warning: <><path {...ICON_PROPS} d="M12 3 22 20H2L12 3Z" /><path {...ICON_PROPS} d="M12 8v5m0 3v.2" /></>,
|
||||
"arrow-left": <path {...ICON_PROPS} d="m14 5-7 7 7 7" />,
|
||||
"arrow-right": <path {...ICON_PROPS} d="m10 5 7 7-7 7" />,
|
||||
reset: <><path {...ICON_PROPS} d="M4 12a8 8 0 1 0 2-5.3" /><path {...ICON_PROPS} d="M4 4v4h4" /></>,
|
||||
info: <><circle {...ICON_PROPS} cx="12" cy="12" r="8" /><path {...ICON_PROPS} d="M12 10v5m0-8v.2" /></>,
|
||||
export: <><path {...ICON_PROPS} d="M12 3v12m0 0 4-4m-4 4-4-4M5 20h14" /></>,
|
||||
check: <path {...ICON_PROPS} d="m5 12 4.2 4.2L19.5 6" />,
|
||||
privacy: <><path {...ICON_PROPS} d="M12 3 20 6v5c0 4.8-3.2 8.2-8 10-4.8-1.8-8-5.2-8-10V6l8-3Z" /><path {...ICON_PROPS} d="m8.5 12 2.2 2.2 4.8-4.8" /></>,
|
||||
}[name];
|
||||
return <svg viewBox="0 0 24 24" aria-hidden={title ? undefined : true} role={title ? "img" : undefined} {...props}>{title ? <title>{title}</title> : null}{content}</svg>;
|
||||
}
|
||||
|
||||
/** The canonical evidence-lens mark, shared by the browser package and topbar. */
|
||||
export function EventLensMark(props: SVGProps<SVGSVGElement>) {
|
||||
return <svg viewBox="0 0 64 64" aria-hidden="true" {...props}>
|
||||
<circle cx="27" cy="27" r="16" fill="none" stroke="#65d9d0" strokeWidth="5" />
|
||||
<path d="M16 29h22" fill="none" stroke="#dff8f5" strokeWidth="2.7" strokeLinecap="round" />
|
||||
<circle cx="20" cy="29" r="2.4" fill="#65d9d0" />
|
||||
<circle cx="27" cy="29" r="2.4" fill="#65d9d0" />
|
||||
<circle cx="34" cy="29" r="2.4" fill="#65d9d0" />
|
||||
<path d="m39 39 12 12" fill="none" stroke="#f4b860" strokeWidth="6" strokeLinecap="round" />
|
||||
</svg>;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { Analysis, Incident } from "../types";
|
||||
import { sourceLabel, statusLabel } from "../app/format";
|
||||
import { WORKSPACES, type Workspace } from "../app/model";
|
||||
import { EventLensMark, Icon } from "./icons";
|
||||
|
||||
const WORKSPACE_LABELS: Record<Workspace, string> = { analysis: "Analysis", timeline: "Timeline", hunting: "Hunting", reports: "Reports", settings: "Settings" };
|
||||
|
||||
export function WorkspaceNav({ activeWorkspace, onNavigate }: { activeWorkspace: Workspace; onNavigate: (workspace: Workspace) => void }) {
|
||||
const activeButton = useRef<HTMLButtonElement>(null);
|
||||
useEffect(() => {
|
||||
activeButton.current?.scrollIntoView({ block: "nearest", inline: "center" });
|
||||
}, [activeWorkspace]);
|
||||
return <nav className="workspace-nav" aria-label="EventLens workspaces">
|
||||
{WORKSPACES.map((workspace) => <button key={workspace} ref={activeWorkspace === workspace ? activeButton : undefined} type="button" className={activeWorkspace === workspace ? "active" : ""} aria-current={activeWorkspace === workspace ? "page" : undefined} onClick={() => onNavigate(workspace)}><Icon name={workspace} /><span>{WORKSPACE_LABELS[workspace]}</span></button>)}
|
||||
</nav>;
|
||||
}
|
||||
|
||||
export function AppTopbar({ activeWorkspace, onNavigate, version }: { activeWorkspace: Workspace; onNavigate: (workspace: Workspace) => void; version: string }) {
|
||||
return <header className="event-topbar"><div className="event-brand"><span className="event-logo"><EventLensMark /></span><div><strong>EventLens</strong><small>Windows incident reconstruction</small></div></div><WorkspaceNav activeWorkspace={activeWorkspace} onNavigate={onNavigate} /><div className="instance-status"><span aria-hidden="true" /><div><b>Local Instance</b><small>v{version}</small></div></div></header>;
|
||||
}
|
||||
|
||||
export function InvestigationContext({ data, activeIncident, visibleCount, workspace }: { data: Analysis | null; activeIncident: Incident | null; visibleCount?: number; workspace: Workspace }) {
|
||||
if (workspace === "settings") return <section className="investigation-context preferences-context" aria-label="Local preferences context"><div className="investigation-source"><Icon name="settings" /><span><b>Local preferences</b><small>Interface preferences for this session</small></span></div><div className="context-facts"><span>Event text, files and evidence are never stored here</span></div></section>;
|
||||
if (!data) return null;
|
||||
return <section className="investigation-context" aria-label="Current investigation context"><div className="investigation-source"><Icon name="file" /><span><b title={data.source}>{sourceLabel(data.source)}</b><small>Current forensic session</small></span></div><div className="context-facts"><span className={`status-badge status-${data.parse?.status ?? "demo"}`}>{statusLabel(data)}</span><span>{data.events.length.toLocaleString()} events</span><span>{data.incidents.length} incident{data.incidents.length === 1 ? "" : "s"}</span>{typeof visibleCount === "number" ? <span>{visibleCount.toLocaleString()} visible</span> : null}</div>{activeIncident ? <span className="context-incident" title={activeIncident.title}>Incident: <b>{activeIncident.title}</b></span> : null}</section>;
|
||||
}
|
||||
|
||||
export function GlobalActions({ loading, dragging, onOpen, onDemo, workspace }: { loading: boolean; dragging: boolean; onOpen: () => void; onDemo: () => void; workspace: Workspace }) {
|
||||
const secondary = workspace === "reports" || workspace === "settings";
|
||||
return <section className={`global-actions ${dragging ? "dragging" : ""} ${secondary ? "global-actions-secondary" : ""}`} aria-label="Analysis actions"><div className="action-buttons"><button type="button" className="primary-button" onClick={onOpen} disabled={loading}><Icon name="upload" />{loading ? "Analyzing…" : "Open .evtx"}</button><button type="button" className="secondary-button" onClick={onDemo} disabled={loading}><Icon name="demo" />Load demo</button><span className="drop-hint">Drop an .evtx file anywhere in the workspace</span></div><span className="context-muted">Local, in-memory analysis</span></section>;
|
||||
}
|
||||
|
||||
export function Statusbar({ data }: { data: Analysis | null }) {
|
||||
return <footer className="event-statusbar"><span><Icon name="file" /> {data ? sourceLabel(data.source) : "No source loaded"}</span><span><Icon name="info" /> {data ? `${data.events.length.toLocaleString()} events` : "Local only"}</span><span className={data?.parse?.status === "partial" ? "partial" : "complete"}><i className="status-dot" aria-hidden="true" /> {statusLabel(data)}</span></footer>;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { Incident, ParseStats } from "../types";
|
||||
import { formatRange } from "../app/format";
|
||||
import type { TimestampDisplay } from "../app/model";
|
||||
import { Icon } from "./icons";
|
||||
|
||||
export function WorkspaceHeader({ eyebrow, title, description, action }: { eyebrow: string; title: string; description: string; action?: ReactNode }) {
|
||||
return <header className="workspace-header"><div><p className="eyebrow">{eyebrow}</p><h1>{title}</h1><p className="workspace-description">{description}</p></div>{action ? <div className="workspace-header-action">{action}</div> : null}</header>;
|
||||
}
|
||||
|
||||
export function EmptyState({ onOpen, onDemo, title = "No event log loaded", description = "Open a local EVTX file or load the deterministic demo to begin an evidence-based investigation." }: { onOpen: () => void; onDemo: () => void; title?: string; description?: string }) {
|
||||
return <section className="empty-state" aria-live="polite"><div className="empty-state-mark"><Icon name="file" /></div><div><h2>{title}</h2><p>{description}</p><ol className="workflow-steps" aria-label="EventLens investigation workflow"><li>EVTX</li><li>Normalize</li><li>Correlate</li><li>Inspect evidence</li><li>Review/export</li></ol><p className="empty-privacy">Analysis stays local, uploads are not intentionally persisted, parsing is bounded, and exports have a redaction preview.</p><div className="empty-actions"><button type="button" className="primary-button" onClick={onOpen}><Icon name="upload" />Open .evtx</button><button type="button" className="secondary-button" onClick={onDemo}><Icon name="demo" />Load demo</button></div></div></section>;
|
||||
}
|
||||
|
||||
export function PartialAnalysisWarning({ parse }: { parse?: ParseStats | null }) {
|
||||
if (!parse || parse.status !== "partial") return null;
|
||||
return <section className="partial-warning" role="alert" aria-live="assertive"><div className="notice-title"><Icon name="warning" /><div><strong>Partial analysis — incident reconstruction may be incomplete</strong><span>{parse.parsed_events.toLocaleString()} events retained from {parse.records_seen.toLocaleString()} records seen; {parse.skipped_records.toLocaleString()} observed record(s) were not retained.</span></div></div><p>Review parser issues before treating the timeline or exports as a complete investigation.</p><ul>{parse.issues.slice(0, 5).map((issue, index) => <li key={`${issue.code}-${issue.record_id ?? "none"}-${index}`}><code>{issue.code}</code> — {issue.message}</li>)}</ul></section>;
|
||||
}
|
||||
|
||||
export function ParserIssueNotice({ parse }: { parse?: ParseStats | null }) {
|
||||
if (!parse || !parse.issues.length) return null;
|
||||
return <section className="parser-issues" role="status"><strong>Parser notes</strong><span>{parse.issues.length} non-fatal parser issue(s) were retained with the evidence. Review data-quality notes before exporting.</span><ul>{parse.issues.slice(0, 4).map((issue, index) => <li key={`${issue.code}-${issue.record_id ?? "none"}-${index}`}><code>{issue.code}</code> — {issue.message}</li>)}</ul></section>;
|
||||
}
|
||||
|
||||
export function ErrorBanner({ message, onDemo }: { message: string; onDemo: () => void }) {
|
||||
return <section className="error-banner" role="alert"><div><strong>Analysis could not be completed</strong><span>{message}</span></div><button type="button" className="text-button" onClick={onDemo}>Load demo instead</button></section>;
|
||||
}
|
||||
|
||||
export function MetricCard({ icon, value, label, tone = "blue" }: { icon: ReactNode; value: string | number; label: string; tone?: "blue" | "amber" | "green" }) {
|
||||
return <div className={`metric-card tone-${tone}`}><span className="metric-icon">{icon}</span><strong>{value}</strong><span>{label}</span></div>;
|
||||
}
|
||||
|
||||
export function IncidentCard({ incident, timestampDisplay, selected, onOpen }: { incident: Incident; timestampDisplay: TimestampDisplay; selected: boolean; onOpen: (incident: Incident) => void }) {
|
||||
const severity = incident.severity === "critical" || incident.severity === "error" ? "high" : incident.severity === "warning" ? "medium" : "info";
|
||||
return <button type="button" className={`incident-card severity-${severity} ${selected ? "selected" : ""}`} onClick={() => onOpen(incident)}><div className="incident-card-heading"><span className="incident-icon"><Icon name="warning" /></span><strong>{incident.title}</strong><span className="severity-pill">{severity === "high" ? "High" : severity === "medium" ? "Medium" : "Info"}</span></div><p>{incident.reasons[0] ?? "Evidence was grouped by a deterministic EventLens rule."}</p><footer><span>{formatRange(incident.start, incident.end, timestampDisplay)}</span><b>{incident.original_count} event{incident.original_count === 1 ? "" : "s"}</b></footer><span className="incident-card-action">Inspect evidence <Icon name="arrow-right" /></span></button>;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Analysis, EventItem, Incident } from "../types";
|
||||
import { formatClock, formatDate, formatDateTime, levelLabel } from "../app/format";
|
||||
import { CATEGORIES, LEVELS, OVERSCAN, WINDOWS, type FilterState, type InspectorTab, type TimestampDisplay } from "../app/model";
|
||||
import { Icon } from "./icons";
|
||||
|
||||
export function filterCount(filters: FilterState): number {
|
||||
return [filters.search, filters.category, filters.level, filters.provider, filters.windowMinutes > 0 ? filters.windowMinutes : "", filters.attentionOnly ? "attention" : ""].filter(Boolean).length;
|
||||
}
|
||||
|
||||
export function FilterToolbar({ filters, providers, visibleCount, totalCount, anchorTimestamp, activeIncident, focusIncident, onFocusChange, onChange, onReset }: { filters: FilterState; providers: string[]; visibleCount: number; totalCount: number; anchorTimestamp: string | null; activeIncident: Incident | null; focusIncident: boolean; onFocusChange: (value: boolean) => void; onChange: (patch: Partial<FilterState>) => void; onReset: () => void }) {
|
||||
const active = filterCount(filters);
|
||||
return <section className="filter-toolbar" aria-label="Timeline filters"><div className="filter-primary"><label className="search-field"><Icon name="search" /><span className="sr-only">Search normalized evidence</span><input aria-label="Search normalized evidence" value={filters.search} onChange={(event) => onChange({ search: event.target.value })} placeholder="Search evidence, paths, IDs, fields…" /></label><span className="result-count" role="status"><strong>{visibleCount.toLocaleString()}</strong> of {totalCount.toLocaleString()} visible</span></div><div className="filter-secondary"><label><span>Category</span><select aria-label="Filter by category" value={filters.category} onChange={(event) => onChange({ category: event.target.value })}><option value="">All categories</option>{CATEGORIES.map((value) => <option key={value}>{value}</option>)}</select></label><label><span>Level</span><select aria-label="Filter by level" value={filters.level} onChange={(event) => onChange({ level: event.target.value })}><option value="">All levels</option>{LEVELS.map((value) => <option key={value}>{value}</option>)}</select></label><label className="provider-filter"><span>Provider</span><select aria-label="Filter by provider" value={filters.provider} onChange={(event) => onChange({ provider: event.target.value })}><option value="">All providers</option>{providers.map((value) => <option key={value}>{value}</option>)}</select></label><label><span>Time window</span><select aria-label="Evidence time window" value={filters.windowMinutes} onChange={(event) => onChange({ windowMinutes: Number(event.target.value) })} disabled={!anchorTimestamp}><option value={0}>All time</option>{WINDOWS.filter(Boolean).map((value) => <option value={value} key={value}>± {value} min</option>)}</select></label></div><div className="filter-actions"><label className="toggle-control"><input type="checkbox" checked={filters.attentionOnly} onChange={(event) => onChange({ attentionOnly: event.target.checked })} /><span className="toggle-track" /><span>Hide informational noise</span></label><label className="toggle-control"><input type="checkbox" checked={focusIncident} onChange={(event) => onFocusChange(event.target.checked)} disabled={!activeIncident} /><span className="toggle-track" /><span>Focus incident</span></label><button type="button" className="reset-button" onClick={onReset} disabled={!active}><Icon name="reset" />Reset filters{active ? ` (${active})` : ""}</button></div>{active || (focusIncident && activeIncident) ? <div className="filter-chips" aria-label="Active filters">{filters.search ? <span>Search: {filters.search}</span> : null}{filters.category ? <span>Category: {filters.category}</span> : null}{filters.level ? <span>Level: {filters.level}</span> : null}{filters.provider ? <span>Provider: {filters.provider}</span> : null}{filters.windowMinutes ? <span>± {filters.windowMinutes} min</span> : null}{filters.attentionOnly ? <span>Informational hidden</span> : null}{focusIncident && activeIncident ? <span>Incident focus: {activeIncident.title}</span> : null}</div> : null}</section>;
|
||||
}
|
||||
|
||||
export function IncidentContext({ incidents, activeIncidentId, focusIncident, onSelect, onFocus }: { incidents: Incident[]; activeIncidentId: string; focusIncident: boolean; onSelect: (id: string) => void; onFocus: (value: boolean) => void }) {
|
||||
if (!incidents.length) return null;
|
||||
const active = incidents.find((incident) => incident.id === activeIncidentId) ?? null;
|
||||
return <section className="incident-context"><label><span>Selected incident</span><select aria-label="Select incident" value={activeIncidentId} onChange={(event) => onSelect(event.target.value)}><option value="">No incident selected</option>{incidents.map((incident) => <option key={incident.id} value={incident.id}>{incident.title}</option>)}</select></label>{active ? <div className="incident-context-detail"><span className={`incident-focus-chip ${focusIncident ? "active" : ""}`}>{focusIncident ? "Focus active" : "Incident selected"}</span><span>{active.evidence_event_ids.length} linked evidence IDs</span><button type="button" className="text-button" onClick={() => onFocus(!focusIncident)}>{focusIncident ? "Stop focus" : "Focus evidence"}</button></div> : null}</section>;
|
||||
}
|
||||
|
||||
function EventRow({ event, selected, position, total, rowHeight, timestampDisplay, onSelect }: { event: EventItem; selected: boolean; position: number; total: number; rowHeight: number; timestampDisplay: TimestampDisplay; onSelect: (event: EventItem) => void }) {
|
||||
return <div role="listitem" aria-posinset={position} aria-setsize={total} style={{ height: rowHeight }}><button type="button" className={`event-row ${selected ? "active" : ""}`} style={{ height: rowHeight }} aria-label={`${event.provider}, event ${event.event_id ?? "unknown"}, ${event.summary}`} onClick={() => onSelect(event)}><span className={`event-rail rail-${event.level}`} aria-hidden="true"><span /></span><time>{formatClock(event.timestamp, timestampDisplay)}</time><b className="event-id">{event.event_id ?? "—"}</b><span className="event-copy"><strong>{event.provider}</strong><span>{event.summary}</span></span><span className="event-host">{event.computer || "—"}</span><span className={`event-level level-${event.level}`}>{levelLabel(event.level)}</span></button></div>;
|
||||
}
|
||||
|
||||
export function VirtualizedEventList({ events, selectedId, rowHeight, timestampDisplay, onSelect }: { events: EventItem[]; selectedId?: string | null; rowHeight: number; timestampDisplay: TimestampDisplay; onSelect: (event: EventItem) => void }) {
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const [viewportHeight, setViewportHeight] = useState(520);
|
||||
useEffect(() => {
|
||||
const node = listRef.current;
|
||||
if (!node) return;
|
||||
const update = () => setViewportHeight(node.clientHeight || 520);
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
if (!events.length) return <div className="event-list empty-list"><p role="status">No events match the current filters. Reset a filter or show informational noise to continue.</p></div>;
|
||||
const first = Math.max(0, Math.floor(scrollTop / rowHeight) - OVERSCAN);
|
||||
const visibleCount = Math.ceil(viewportHeight / rowHeight) + OVERSCAN * 2;
|
||||
const last = Math.min(events.length, first + visibleCount);
|
||||
return <div className="event-list" ref={listRef} onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)} role="list" aria-label="Normalized event timeline"><div className="event-list-inner" style={{ height: events.length * rowHeight }}><div className="event-list-window" style={{ transform: `translateY(${first * rowHeight}px)` }}>{events.slice(first, last).map((event, index) => <EventRow key={event.id} event={event} selected={selectedId === event.id} position={first + index + 1} total={events.length} rowHeight={rowHeight} timestampDisplay={timestampDisplay} onSelect={onSelect} />)}</div></div></div>;
|
||||
}
|
||||
|
||||
export type InspectorProps = {
|
||||
data: Analysis;
|
||||
event: EventItem | null;
|
||||
visibleEvents: EventItem[];
|
||||
activeIncident: Incident | null;
|
||||
timestampDisplay: TimestampDisplay;
|
||||
tab: InspectorTab;
|
||||
onTab: (tab: InspectorTab) => void;
|
||||
onSelectEvent: (event: EventItem) => void;
|
||||
onMove: (delta: number) => void;
|
||||
onOpenTimeline?: () => void;
|
||||
};
|
||||
|
||||
export function EvidenceInspector({ data, event, visibleEvents, activeIncident, timestampDisplay, tab, onTab, onSelectEvent, onMove, onOpenTimeline }: InspectorProps) {
|
||||
const selectedIndex = event ? visibleEvents.findIndex((item) => item.id === event.id) : -1;
|
||||
const relatedIncident = event ? data.incidents.find((incident) => incident.evidence_event_ids.includes(event.id)) ?? activeIncident : activeIncident;
|
||||
const relatedEvents = relatedIncident ? data.events.filter((item) => relatedIncident.evidence_event_ids.includes(item.id)) : [];
|
||||
const copy = async (value: string) => { if (navigator.clipboard) await navigator.clipboard.writeText(value); };
|
||||
return <aside className="inspector-panel"><header className="panel-header"><div><p className="eyebrow">Evidence</p><h2>Inspector</h2></div><div className="inspector-navigation"><button type="button" aria-label="Previous evidence" onClick={() => onMove(-1)} disabled={!visibleEvents.length || selectedIndex <= 0}><Icon name="arrow-left" /></button><span>{selectedIndex >= 0 ? selectedIndex + 1 : 0} of {visibleEvents.length}</span><button type="button" aria-label="Next evidence" onClick={() => onMove(1)} disabled={!visibleEvents.length || selectedIndex === visibleEvents.length - 1}><Icon name="arrow-right" /></button></div></header>{event ? <><div className="inspector-title"><div><h3>{event.summary}</h3><p>Event ID {event.event_id ?? "—"} · {event.provider}</p></div><span className={`event-level level-${event.level}`}>{levelLabel(event.level)}</span></div>{onOpenTimeline ? <div className="inspector-actions"><button type="button" className="text-button" onClick={onOpenTimeline}>Open in Timeline <Icon name="arrow-right" /></button></div> : null}<div className="inspector-tabs" role="tablist" aria-label="Evidence views"><button type="button" role="tab" aria-selected={tab === "details"} className={tab === "details" ? "active" : ""} onClick={() => onTab("details")}>Details</button><button type="button" role="tab" aria-selected={tab === "raw"} className={tab === "raw" ? "active" : ""} onClick={() => onTab("raw")}>Normalized JSON</button><button type="button" role="tab" aria-selected={tab === "related"} className={tab === "related" ? "active" : ""} onClick={() => onTab("related")}>Related ({relatedEvents.length})</button><button type="button" role="tab" aria-selected={tab === "insights"} className={tab === "insights" ? "active" : ""} onClick={() => onTab("insights")}>Insights</button></div>{tab === "details" ? <div className="inspector-content"><div className="copy-row"><button type="button" className="quiet-button" onClick={() => void copy(event.id)}>Copy event ID</button><button type="button" className="quiet-button" onClick={() => void copy(JSON.stringify(event, null, 2))}>Copy JSON</button></div><dl className="fact-grid"><div><dt>Timestamp</dt><dd>{formatDateTime(event.timestamp, timestampDisplay)}</dd></div><div><dt>Timestamp source</dt><dd>{event.timestamp_source ?? (event.timestamp ? "event" : "missing")} · {event.timestamp_valid === false ? "unreliable" : "valid"}</dd></div><div><dt>Provider</dt><dd>{event.provider}</dd></div><div><dt>Event ID</dt><dd>{event.event_id ?? "—"}</dd></div><div><dt>Level</dt><dd><span className={`level-dot ${event.level}`} />{event.level}</dd></div><div><dt>Category</dt><dd>{event.category}</dd></div><div><dt>Channel</dt><dd>{event.channel || "—"}</dd></div><div><dt>Computer</dt><dd>{event.computer || "—"}</dd></div></dl><section className="normalized-fields"><header><b>Normalized fields</b><span>{Object.keys(event.fields ?? {}).length}</span></header>{Object.keys(event.fields ?? {}).length ? <div>{Object.entries(event.fields ?? {}).map(([key, value]) => <p key={key}><code>{key.replace(/^EventData\./, "").replace(/^UserData\./, "")}</code><span>{value}</span></p>)}</div> : <p className="muted-copy">No additional normalized fields.</p>}</section></div> : null}{tab === "raw" ? <pre className="inspector-raw">{JSON.stringify(event, null, 2)}</pre> : null}{tab === "related" ? <div className="inspector-content related-events">{relatedIncident ? <><p className="muted-copy">{relatedIncident.title} · {relatedIncident.rule_id}</p>{relatedEvents.map((related) => <button type="button" className={related.id === event.id ? "related-event active" : "related-event"} key={related.id} onClick={() => onSelectEvent(related)}><span>{formatClock(related.timestamp, timestampDisplay)}</span><strong>{related.event_id ?? "—"}</strong><span>{related.summary}</span></button>)}</> : <p className="muted-copy">Select an incident to inspect related evidence.</p>}</div> : null}{tab === "insights" ? <div className="inspector-content insight-copy"><p><strong>Evidence relationship</strong></p><p>{relatedIncident ? relatedIncident.reasons[0] ?? "This event is linked by a deterministic rule." : "This event is not currently linked to a selected incident."}</p><p>EventLens describes evidence-supported temporal or semantic relationships. It does not claim causality.</p></div> : null}</> : <div className="inspector-placeholder"><p>No visible event is selected.</p><span>Clear a filter or show informational noise to continue.</span></div>}</aside>;
|
||||
}
|
||||
|
||||
export function TimelinePanel({ events, selectedId, rowHeight, timestampDisplay, onSelect }: { events: EventItem[]; selectedId: string | null; rowHeight: number; timestampDisplay: TimestampDisplay; onSelect: (event: EventItem) => void }) {
|
||||
return <article className="timeline-panel panel"><header className="panel-header"><div><p className="eyebrow">Chronological evidence</p><h2>Event timeline</h2></div><span className="panel-count">{events.length.toLocaleString()} visible</span></header><div className="date-band">{formatDate(events[0]?.timestamp ?? null, timestampDisplay)}</div><VirtualizedEventList events={events} selectedId={selectedId} rowHeight={rowHeight} timestampDisplay={timestampDisplay} onSelect={onSelect} /></article>;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { analysisToMarkdown, prepareExport } from "./export";
|
||||
import type { Analysis } from "./types";
|
||||
|
||||
const analysis: Analysis = { source:"demo.evtx", incidents:[], events:[{id:"1",timestamp:"2026-09-09T10:00:00Z",provider:"Security",event_id:4625,level:"warning",category:"authentication",summary:"Login for admin@example.com",computer:"PC-01",fields:{"EventData.TargetUserName":"admin"}}] };
|
||||
|
||||
describe("export", () => {
|
||||
it("redacts by default", () => {
|
||||
const prepared = prepareExport(analysis, true);
|
||||
expect(prepared.analysis.events[0].computer).toBe("[REDACTED_HOST]");
|
||||
expect(prepared.analysis.source).toBe("[REDACTED_SOURCE].evtx");
|
||||
expect(prepared.redaction?.total).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("produces an incident/evidence markdown report", () => {
|
||||
const markdown = analysisToMarkdown(analysis, true);
|
||||
expect(markdown).toContain("# EventLens incident report — [REDACTED_SOURCE].evtx");
|
||||
expect(markdown).toContain("[REDACTED_EMAIL]");
|
||||
expect(markdown).toContain("## Evidence events");
|
||||
});
|
||||
|
||||
it("escapes markdown control syntax from untrusted event text", () => {
|
||||
const malicious: Analysis = {
|
||||
source: "report#](https://example.invalid).evtx",
|
||||
incidents: [{ id: "i", rule_id: "rule|x", start: null, end: null, title: "# heading", category: "other", severity: "warning", reasons: [" <img src=x> ``` | table |"], evidence_event_ids: ["1"], original_count: 1, compressed_count: 1 }],
|
||||
events: [{ id: "1", timestamp: null, provider: "Provider", event_id: 1, level: "warning", category: "other", summary: "# heading [click](https://example.invalid) <script>alert(1)</script>", fields: { payload: "```\n| remote |" } }],
|
||||
};
|
||||
const markdown = analysisToMarkdown(malicious, false);
|
||||
expect(markdown).not.toContain("\n");
|
||||
expect(markdown).not.toContain("\n<img");
|
||||
expect(markdown).not.toContain("\n<script>");
|
||||
expect(markdown).toContain("unknown timestamp");
|
||||
expect(markdown).toContain("https://example.invalid");
|
||||
});
|
||||
|
||||
it("keeps partial completeness metadata aligned across JSON and Markdown", () => {
|
||||
const partial: Analysis = {
|
||||
...analysis,
|
||||
parse: {
|
||||
parser: "evtx-rs",
|
||||
status: "partial",
|
||||
parsed_events: 1,
|
||||
records_seen: 4,
|
||||
skipped_records: 2,
|
||||
issue_count: 1,
|
||||
truncated: true,
|
||||
truncation_reason: "event_limit_reached",
|
||||
duration_ms: 12,
|
||||
issues: [{ code: "event_limit_reached", record_id: 4, message: "later records were not analyzed", skipped_record: true }],
|
||||
},
|
||||
};
|
||||
const json = prepareExport(partial, false).analysis;
|
||||
const markdown = analysisToMarkdown(partial, false);
|
||||
expect(json.parse?.status).toBe("partial");
|
||||
expect(json.parse?.skipped_records).toBe(2);
|
||||
expect(markdown).toContain("PARTIAL — incident reconstruction may be incomplete");
|
||||
expect(markdown).toContain("event_limit_reached");
|
||||
expect(markdown).toContain("skipped 2");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { redactAnalysis, type RedactionSummary } from "./redact";
|
||||
import type { Analysis, Incident, ParseIssue } from "./types";
|
||||
|
||||
export type PreparedExport = { analysis: Analysis; redaction: RedactionSummary | null };
|
||||
|
||||
const REDACTION_TOKEN_RE = /^\[REDACTED_[A-Z]+\](?:\.evtx)?$/;
|
||||
|
||||
function markdownText(value: string): string {
|
||||
const compact = value.replace(/[\r\n]+/g, " ");
|
||||
return compact.split(/(\[REDACTED_[A-Z]+\])/g).map((part) => {
|
||||
if (REDACTION_TOKEN_RE.test(part)) return part;
|
||||
return part
|
||||
.replaceAll("\\", "\\\\")
|
||||
.replaceAll("`", "\\`")
|
||||
.replaceAll("*", "\\*")
|
||||
.replaceAll("_", "\\_")
|
||||
.replaceAll("[", "\\[")
|
||||
.replaceAll("]", "\\]")
|
||||
.replaceAll("(", "\\(")
|
||||
.replaceAll(")", "\\)")
|
||||
.replaceAll("!", "\\!")
|
||||
.replaceAll("#", "\\#")
|
||||
.replaceAll(">", "\\>")
|
||||
.replaceAll("<", "\\<")
|
||||
.replaceAll("|", "\\|");
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function markdownCode(value: string): string {
|
||||
const normalized = value.replace(/[\r\n]+/g, " ↵ ");
|
||||
const runs = normalized.match(/`+/g) ?? [];
|
||||
const delimiter = "`".repeat(Math.max(1, ...runs.map((run) => run.length + 1)));
|
||||
return `${delimiter} ${normalized} ${delimiter}`;
|
||||
}
|
||||
|
||||
function displayTime(value: string | null): string {
|
||||
return value ? value : "unknown timestamp (not used for temporal correlation)";
|
||||
}
|
||||
|
||||
function issueLine(issue: ParseIssue): string {
|
||||
const record = issue.record_id == null ? "—" : String(issue.record_id);
|
||||
return `- ${markdownCode(issue.code)} · record ${markdownCode(record)} · ${markdownCode(issue.message)}`;
|
||||
}
|
||||
|
||||
function incidentWindow(incident: Incident): string {
|
||||
return `${displayTime(incident.start)} → ${displayTime(incident.end)}`;
|
||||
}
|
||||
|
||||
export function prepareExport(source: Analysis, redact: boolean): PreparedExport {
|
||||
if (!redact) return { analysis: source, redaction: null };
|
||||
const result = redactAnalysis(source);
|
||||
return { analysis: result.analysis, redaction: result.summary };
|
||||
}
|
||||
|
||||
export function analysisToMarkdown(source: Analysis, redact = true): string {
|
||||
const prepared = prepareExport(source, redact);
|
||||
const analysis = prepared.analysis;
|
||||
const lines = [
|
||||
`# EventLens incident report — ${markdownText(analysis.source)}`,
|
||||
"",
|
||||
`- Events: ${analysis.events.length}`,
|
||||
`- Incidents: ${analysis.incidents.length}`,
|
||||
`- Redaction: ${redact ? `enabled (${prepared.redaction?.total ?? 0} replacement(s))` : "disabled"}`,
|
||||
analysis.parse
|
||||
? `- Parser status: ${analysis.parse.status}; records seen ${analysis.parse.records_seen}; parsed ${analysis.parse.parsed_events}; skipped ${analysis.parse.skipped_records}; issues ${analysis.parse.issue_count}; truncated ${analysis.parse.truncated ? "yes" : "no"}`
|
||||
: "- Source: deterministic demo fixture",
|
||||
analysis.parse
|
||||
? `- Analysis completeness: ${analysis.parse.status === "partial" ? "PARTIAL — incident reconstruction may be incomplete" : analysis.parse.status.toUpperCase()}`
|
||||
: "- Analysis completeness: DEMO fixture",
|
||||
analysis.parse?.truncation_reason ? `- Truncation reason: ${markdownCode(analysis.parse.truncation_reason)}` : "",
|
||||
"",
|
||||
];
|
||||
if (analysis.parse?.issues.length) {
|
||||
lines.push("## Parser issues", "", ...analysis.parse.issues.map(issueLine), "");
|
||||
}
|
||||
lines.push("## Incidents", "");
|
||||
for (const incident of analysis.incidents) {
|
||||
lines.push(
|
||||
`### ${markdownText(incident.title)}`,
|
||||
`- Rule: ${markdownCode(incident.rule_id)}`,
|
||||
`- Severity: ${markdownText(incident.severity)}`,
|
||||
`- Window: ${markdownCode(incidentWindow(incident))}`,
|
||||
`- Evidence: ${markdownCode(incident.evidence_event_ids.join(", "))}`,
|
||||
...incident.reasons.map((reason) => `- Reason: ${markdownCode(reason)}`),
|
||||
"",
|
||||
);
|
||||
}
|
||||
lines.push("## Evidence events", "");
|
||||
for (const event of analysis.events) {
|
||||
lines.push(
|
||||
`### ${markdownCode(displayTime(event.timestamp))} — ${markdownText(event.summary)}`,
|
||||
`- Record: ${markdownCode(event.id)}`,
|
||||
`- Provider: ${markdownCode(event.provider)}`,
|
||||
`- Event ID: ${event.event_id ?? "—"}`,
|
||||
`- Level: ${markdownText(event.level)}`,
|
||||
`- Category: ${markdownText(event.category)}`,
|
||||
`- Computer: ${markdownCode(event.computer || "—")}`,
|
||||
`- Timestamp source: ${markdownCode(event.timestamp_source ?? (event.timestamp ? "event" : "missing"))}`,
|
||||
`- Timestamp valid: ${event.timestamp_valid === false ? "no" : "yes"}`,
|
||||
);
|
||||
for (const [key, value] of Object.entries(event.fields ?? {})) {
|
||||
lines.push(`- ${markdownCode(key)}: ${markdownCode(value)}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
lines.push("_EventLens redaction is best effort. Review this report before sharing._", "");
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { filterEvents } from "./filter";
|
||||
import type { EventItem } from "./types";
|
||||
|
||||
function makeEvents(count: number): EventItem[] {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: String(index),
|
||||
timestamp: "2026-09-09T10:00:00Z",
|
||||
provider: index % 2 ? "Application Provider" : "Security Provider",
|
||||
event_id: index,
|
||||
level: "warning",
|
||||
category: "other",
|
||||
summary: `Synthetic event ${index}`,
|
||||
fields: { message: index === count - 1 ? "needle-value" : "ordinary event" },
|
||||
}));
|
||||
}
|
||||
|
||||
describe("large result performance", () => {
|
||||
it("searches 100,000 normalized events within a deterministic budget", () => {
|
||||
const events = makeEvents(100_000);
|
||||
const started = performance.now();
|
||||
const result = filterEvents(events, { search: "needle-value", category: "", level: "", provider: "", noiseMode: "all" });
|
||||
const elapsed = performance.now() - started;
|
||||
expect(result.map((event) => event.id)).toEqual(["99999"]);
|
||||
expect(elapsed).toBeLessThan(3_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { filterEvents, uniqueProviders } from "./filter";
|
||||
import type { EventItem } from "./types";
|
||||
|
||||
const events: EventItem[] = [
|
||||
{ id: "1", timestamp: "2026-09-09T10:00:00Z", provider: "DNS Client", event_id: 1014, level: "warning", category: "network", summary: "DNS timeout", computer: "PC-01", fields: { Query: "api.example.test" } },
|
||||
{ id: "2", timestamp: "2026-09-09T10:00:10Z", provider: "Kernel General", event_id: 12, level: "information", category: "system", summary: "System started", computer: "PC-01" },
|
||||
{ id: "3", timestamp: "2026-09-09T10:10:20Z", provider: "Application Error", event_id: 1000, level: "error", category: "crash", summary: "Example.exe crashed", computer: "PC-01" },
|
||||
];
|
||||
|
||||
const base = { search: "", category: "", level: "", provider: "", noiseMode: "all" as const };
|
||||
|
||||
describe("event filters", () => {
|
||||
it("combines category and attention filtering", () => {
|
||||
expect(filterEvents(events, { ...base, category: "system", noiseMode: "attention" })).toEqual([]);
|
||||
expect(filterEvents(events, { ...base, noiseMode: "attention" }).map((event) => event.id)).toEqual(["1", "3"]);
|
||||
});
|
||||
|
||||
it("searches normalized evidence fields", () => {
|
||||
expect(filterEvents(events, { ...base, search: "api.example.test" }).map((event) => event.id)).toEqual(["1"]);
|
||||
});
|
||||
|
||||
it("searches stable evidence IDs", () => {
|
||||
expect(filterEvents(events, { ...base, search: "2" }).map((event) => event.id)).toEqual(["2"]);
|
||||
});
|
||||
|
||||
it("can focus an incident evidence set", () => {
|
||||
expect(filterEvents(events, { ...base, evidenceIds: new Set(["3"]) }).map((event) => event.id)).toEqual(["3"]);
|
||||
});
|
||||
|
||||
it("keeps informational incident evidence when focus overrides noise suppression", () => {
|
||||
expect(filterEvents(events, { ...base, noiseMode: "attention", evidenceIds: new Set(["2"]) }).map((event) => event.id)).toEqual(["2"]);
|
||||
});
|
||||
|
||||
it("applies a symmetric time window around selected evidence", () => {
|
||||
expect(filterEvents(events, { ...base, anchorTimestamp: "2026-09-09T10:00:10Z", windowMinutes: 1 }).map((event) => event.id)).toEqual(["1", "2"]);
|
||||
});
|
||||
|
||||
it("returns stable sorted provider options", () => {
|
||||
expect(uniqueProviders(events)).toEqual(["Application Error", "DNS Client", "Kernel General"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { EventItem } from "./types";
|
||||
|
||||
export type NoiseMode = "all" | "attention";
|
||||
|
||||
export type EventFilters = {
|
||||
search: string;
|
||||
category: string;
|
||||
level: string;
|
||||
provider: string;
|
||||
noiseMode: NoiseMode;
|
||||
evidenceIds?: Set<string> | null;
|
||||
windowMinutes?: number | null;
|
||||
anchorTimestamp?: string | null;
|
||||
};
|
||||
|
||||
const ATTENTION_LEVELS = new Set(["critical", "error", "warning"]);
|
||||
const searchDocuments = new WeakMap<EventItem, string>();
|
||||
|
||||
function searchDocument(event: EventItem): string {
|
||||
const cached = searchDocuments.get(event);
|
||||
if (cached) return cached;
|
||||
const fieldText = Object.entries(event.fields ?? {})
|
||||
.map(([key, value]) => `${key} ${value}`)
|
||||
.join(" ");
|
||||
const document = [event.id, event.summary, event.provider, event.event_id ?? "", event.category, event.channel ?? "", event.computer ?? "", fieldText]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
searchDocuments.set(event, document);
|
||||
return document;
|
||||
}
|
||||
|
||||
export function filterEvents(events: EventItem[], filters: EventFilters): EventItem[] {
|
||||
const search = filters.search.trim().toLowerCase();
|
||||
const parsedAnchor = filters.anchorTimestamp ? Date.parse(filters.anchorTimestamp) : Number.NaN;
|
||||
const anchor = Number.isFinite(parsedAnchor) ? parsedAnchor : null;
|
||||
const windowMs = filters.windowMinutes && filters.windowMinutes > 0 ? filters.windowMinutes * 60_000 : null;
|
||||
return events.filter((event) => {
|
||||
if (filters.evidenceIds && !filters.evidenceIds.has(event.id)) return false;
|
||||
if (filters.category && event.category !== filters.category) return false;
|
||||
if (filters.level && event.level.toLowerCase() !== filters.level.toLowerCase()) return false;
|
||||
if (filters.provider && event.provider !== filters.provider) return false;
|
||||
if (filters.noiseMode === "attention" && !filters.evidenceIds && !ATTENTION_LEVELS.has(event.level.toLowerCase())) return false;
|
||||
if (anchor !== null && windowMs !== null) {
|
||||
if (!event.timestamp) return false;
|
||||
const eventTime = Date.parse(event.timestamp);
|
||||
if (!Number.isFinite(eventTime) || Math.abs(eventTime - anchor) > windowMs) return false;
|
||||
}
|
||||
if (!search) return true;
|
||||
return searchDocument(event).includes(search);
|
||||
});
|
||||
}
|
||||
|
||||
export function uniqueProviders(events: EventItem[]): string[] {
|
||||
return [...new Set(events.map((event) => event.provider).filter(Boolean))].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./app/App";
|
||||
import "./styles.css";
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
if (!rootElement) throw new Error("App root element was not found");
|
||||
|
||||
createRoot(rootElement).render(<React.StrictMode><App /></React.StrictMode>);
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { redactAnalysis } from "./redact";
|
||||
import type { Analysis } from "./types";
|
||||
|
||||
const source: Analysis = {
|
||||
source: "Security.evtx",
|
||||
incidents: [{ id:"i1", rule_id:"windows.auth.failed-logon", start:"2026-09-09T10:00:00Z", end:"2026-09-09T10:00:01Z", title:"Failed login", category:"authentication", severity:"warning", reasons:["Connection from 192.168.1.15"], evidence_event_ids:["1"], original_count:1, compressed_count:1 }],
|
||||
events: [{ id:"1", timestamp:"2026-09-09T10:00:00Z", provider:"Security", event_id:4625, level:"warning", category:"authentication", summary:"C:\\Users\\jens\\tool.exe contacted admin@example.com at 192.168.1.15", computer:"DEMO-PC", fields:{"EventData.TargetUserName":"jens","EventData.IpAddress":"10.0.0.5","EventData.ProcessName":"C:\\Windows\\System32\\cmd.exe"} }],
|
||||
};
|
||||
|
||||
describe("redaction", () => {
|
||||
it("redacts sensitive event values without mutating the source", () => {
|
||||
const result = redactAnalysis(source);
|
||||
const event = result.analysis.events[0];
|
||||
expect(event.computer).toBe("[REDACTED_HOST]");
|
||||
expect(event.fields?.["EventData.TargetUserName"]).toBe("[REDACTED_IDENTITY]");
|
||||
expect(event.fields?.["EventData.IpAddress"]).toBe("[REDACTED_IP]");
|
||||
expect(event.fields?.["EventData.ProcessName"]).toBe("[REDACTED_PATH]");
|
||||
expect(event.summary).toContain("[REDACTED_EMAIL]");
|
||||
expect(event.summary).toContain("[REDACTED_IP]");
|
||||
expect(source.events[0].computer).toBe("DEMO-PC");
|
||||
expect(result.summary.total).toBeGreaterThanOrEqual(6);
|
||||
});
|
||||
|
||||
it("propagates identities and redacts Windows/UNC paths and IPv6 in free-form text", () => {
|
||||
const sensitive: Analysis = {
|
||||
source: "private.evtx",
|
||||
incidents: [{
|
||||
id: "incident-1",
|
||||
rule_id: "windows.auth.failed-logon",
|
||||
start: "2026-09-09T10:00:00Z",
|
||||
end: "2026-09-09T10:00:01Z",
|
||||
title: "Failed sign-in for jens",
|
||||
category: "authentication",
|
||||
severity: "warning",
|
||||
reasons: ["jens used C:\\Program Files\\SecretTool\\agent.exe from 2001:db8::42"],
|
||||
evidence_event_ids: ["1"],
|
||||
original_count: 1,
|
||||
compressed_count: 1,
|
||||
}],
|
||||
events: [{
|
||||
id: "1",
|
||||
timestamp: "2026-09-09T10:00:00Z",
|
||||
provider: "Security",
|
||||
event_id: 4625,
|
||||
level: "warning",
|
||||
category: "authentication",
|
||||
summary: "Failed sign-in for DOMAIN\\jens at C:\\Program Files\\SecretTool\\agent.exe from 2001:db8::42, fe80::1 and ::ffff:192.0.2.1; SID S-1-5-21-123-456-789-1001; machine BUILD01$; UNC \\\\server\\Share With Space\\agent.exe",
|
||||
computer: "DEMO-PC",
|
||||
fields: {
|
||||
"EventData.TargetUserName": "jens",
|
||||
"EventData.SubjectUserName": "DOMAIN\\jens",
|
||||
"EventData.ProcessName": "C:\\Program Files\\SecretTool\\agent.exe",
|
||||
"EventData.UncPath": "\\\\server\\Share With Space\\agent.exe",
|
||||
"EventData.MachineAccount": "DOMAIN\\BUILD01$",
|
||||
"EventData.Sid": "S-1-5-21-123-456-789-1001",
|
||||
},
|
||||
}],
|
||||
};
|
||||
const result = redactAnalysis(sensitive);
|
||||
const serialized = JSON.stringify(result.analysis);
|
||||
expect(serialized).not.toContain("jens");
|
||||
expect(serialized).not.toContain("Program Files\\SecretTool");
|
||||
expect(serialized).not.toContain("Share With Space");
|
||||
expect(serialized).not.toContain("2001:db8::42");
|
||||
expect(serialized).not.toContain("fe80::1");
|
||||
expect(serialized).not.toContain("192.0.2.1");
|
||||
expect(serialized).not.toContain("BUILD01$");
|
||||
expect(serialized).not.toContain("S-1-5-21");
|
||||
expect(result.analysis.incidents[0].title).toContain("[REDACTED_IDENTITY]");
|
||||
expect(result.summary.paths).toBeGreaterThan(0);
|
||||
expect(result.summary.ips).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("propagates source-field values into free-form redaction", () => {
|
||||
const sourceField: Analysis = {
|
||||
source: "private.evtx",
|
||||
incidents: [],
|
||||
events: [{
|
||||
id: "1",
|
||||
timestamp: null,
|
||||
provider: "Security",
|
||||
event_id: 1,
|
||||
level: "information",
|
||||
category: "other",
|
||||
summary: "Observed source Collector-A",
|
||||
computer: "",
|
||||
fields: { "EventData.SourceName": "Collector-A" },
|
||||
}],
|
||||
};
|
||||
const result = redactAnalysis(sourceField);
|
||||
expect(result.analysis.events[0].summary).toContain("[REDACTED_SOURCE]");
|
||||
expect(result.summary.sources).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import type { Analysis, EventItem } from "./types";
|
||||
|
||||
export type RedactionSummary = {
|
||||
hosts: number;
|
||||
identities: number;
|
||||
emails: number;
|
||||
ips: number;
|
||||
paths: number;
|
||||
sources: number;
|
||||
total: number;
|
||||
};
|
||||
export type RedactionResult = { analysis: Analysis; summary: RedactionSummary };
|
||||
type RedactionCategory = keyof Omit<RedactionSummary, "total">;
|
||||
type KnownValue = { value: string; replacement: string; category: RedactionCategory };
|
||||
|
||||
const EMAIL_RE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi;
|
||||
const DOMAIN_IDENTITY_RE = /(?<![\w:])[A-Za-z0-9][A-Za-z0-9._-]{1,}\\[A-Za-z0-9._-]{1,}\$?(?![\w])/g;
|
||||
const SID_RE = /\bS-\d(?:-\d+){2,}\b/gi;
|
||||
const MACHINE_ACCOUNT_RE = /\b[A-Za-z0-9][A-Za-z0-9_.-]{1,}\$(?![\w])/g;
|
||||
const IPV4_RE = /(?<![\d.])(?:\d{1,3}\.){3}\d{1,3}(?![\d.])/g;
|
||||
const IPV4_MAPPED_IPV6_RE = /(?<![0-9A-Fa-f:])(?:[0-9A-Fa-f]{0,4}:){2,8}(?:\d{1,3}\.){3}\d{1,3}(?![\w:])/gi;
|
||||
const IPV6_RE = /(?<![0-9A-Fa-f:])(?:[0-9A-Fa-f]{0,4}:){2,8}[0-9A-Fa-f]{0,4}(?![0-9A-Fa-f:])/g;
|
||||
const QUOTED_PATH_RE = /(["'])(?=((?:[A-Za-z]:\\|\\\\)))([\s\S]*?)\1/g;
|
||||
const UNQUOTED_PATH_RE = /(?<![\w])(?:[A-Za-z]:\\|\\\\)(?:[^\\/:*?"<>|\r\n]+\\)+[^\\/:*?"<>|\r\n]*?\.[A-Za-z0-9]{1,8}(?=$|[\s,;:!?)]|["'])/g;
|
||||
const IDENTITY_KEY_RE = /(user(name)?|account|upn|principal|subject.*user|target.*user|machineaccount|sid|email)/i;
|
||||
const EMAIL_KEY_RE = /(email|mail)/i;
|
||||
const HOST_KEY_RE = /(computer|host(name)?|workstation|machine)/i;
|
||||
const PATH_KEY_RE = /(path|filename|image|executable|process(name)?|command(line)?)/i;
|
||||
const IP_KEY_RE = /(^|[._])(ip|ipaddress|sourceaddress|destinationaddress|remoteaddress)$/i;
|
||||
const SOURCE_KEY_RE = /(^|[._])(source|sourcename|sourcefile|logfile)$/i;
|
||||
const REPLACEMENT_RE = /^\[REDACTED_[A-Z]+\]$/;
|
||||
|
||||
function increment(summary: RedactionSummary, category: RedactionCategory): void {
|
||||
summary[category] += 1;
|
||||
summary.total += 1;
|
||||
}
|
||||
|
||||
function replacementFor(category: RedactionCategory): string {
|
||||
return `[REDACTED_${category === "ips" ? "IP" : category === "emails" ? "EMAIL" : category === "hosts" ? "HOST" : category === "paths" ? "PATH" : category === "sources" ? "SOURCE" : "IDENTITY"}]`;
|
||||
}
|
||||
|
||||
function fieldCategory(key: string): RedactionCategory | null {
|
||||
if (EMAIL_KEY_RE.test(key)) return "emails";
|
||||
if (IDENTITY_KEY_RE.test(key)) return "identities";
|
||||
if (HOST_KEY_RE.test(key)) return "hosts";
|
||||
if (PATH_KEY_RE.test(key)) return "paths";
|
||||
if (IP_KEY_RE.test(key)) return "ips";
|
||||
if (SOURCE_KEY_RE.test(key)) return "sources";
|
||||
return null;
|
||||
}
|
||||
|
||||
function addKnown(known: Map<string, KnownValue>, value: string, category: RedactionCategory): void {
|
||||
const candidate = value.trim();
|
||||
if (candidate.length < 2 || REPLACEMENT_RE.test(candidate)) return;
|
||||
const key = `${category}\u0000${candidate.toLocaleLowerCase()}`;
|
||||
if (!known.has(key)) known.set(key, { value: candidate, category, replacement: replacementFor(category) });
|
||||
}
|
||||
|
||||
function collectKnownValues(source: Analysis): Map<string, KnownValue> {
|
||||
const known = new Map<string, KnownValue>();
|
||||
for (const event of source.events) {
|
||||
addKnown(known, event.computer ?? "", "hosts");
|
||||
for (const [key, value] of Object.entries(event.fields ?? {})) {
|
||||
const category = fieldCategory(key);
|
||||
if (category) addKnown(known, value, category);
|
||||
}
|
||||
}
|
||||
return known;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function replaceKnownValues(value: string, known: Map<string, KnownValue>, summary: RedactionSummary): string {
|
||||
let next = value;
|
||||
const values = [...known.values()].sort((a, b) => b.value.length - a.value.length);
|
||||
for (const item of values) {
|
||||
const pattern = new RegExp(`(?<![A-Za-z0-9_])${escapeRegExp(item.value)}(?![A-Za-z0-9_])`, "gi");
|
||||
next = next.replace(pattern, () => {
|
||||
increment(summary, item.category);
|
||||
return item.replacement;
|
||||
});
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function isValidIpv4(value: string): boolean {
|
||||
const octets = value.split(".").map(Number);
|
||||
return octets.length === 4 && octets.every((octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255);
|
||||
}
|
||||
|
||||
function isValidIpv6(value: string): boolean {
|
||||
const withoutZone = value.split("%")[0];
|
||||
const pieces = withoutZone.split(":");
|
||||
const hasCompression = withoutZone.includes("::");
|
||||
if (withoutZone.includes(":::")) return false;
|
||||
const expandedCount = pieces.reduce((count, piece) => {
|
||||
if (!piece) return count;
|
||||
if (piece.includes(".")) return count + 2;
|
||||
return count + 1;
|
||||
}, 0);
|
||||
if (hasCompression ? expandedCount >= 8 : expandedCount !== 8) return false;
|
||||
return pieces.filter(Boolean).every((piece) => piece.includes(".") ? isValidIpv4(piece) : /^[0-9A-Fa-f]{1,4}$/.test(piece));
|
||||
}
|
||||
|
||||
function replacePattern(
|
||||
value: string,
|
||||
pattern: RegExp,
|
||||
replacement: string,
|
||||
category: RedactionCategory,
|
||||
summary: RedactionSummary,
|
||||
validate?: (match: string) => boolean,
|
||||
): string {
|
||||
return value.replace(pattern, (match) => {
|
||||
if (validate && !validate(match)) return match;
|
||||
increment(summary, category);
|
||||
return replacement;
|
||||
});
|
||||
}
|
||||
|
||||
function redactPaths(value: string, summary: RedactionSummary): string {
|
||||
const next = value.replace(QUOTED_PATH_RE, (match, _quote: string, _root: string, path: string) => {
|
||||
if (!path) return match;
|
||||
increment(summary, "paths");
|
||||
return "[REDACTED_PATH]";
|
||||
});
|
||||
return next.replace(UNQUOTED_PATH_RE, () => {
|
||||
increment(summary, "paths");
|
||||
return "[REDACTED_PATH]";
|
||||
});
|
||||
}
|
||||
|
||||
function redactText(value: string, known: Map<string, KnownValue>, summary: RedactionSummary): string {
|
||||
let next = redactPaths(value, summary);
|
||||
next = replacePattern(next, EMAIL_RE, "[REDACTED_EMAIL]", "emails", summary);
|
||||
next = replacePattern(next, DOMAIN_IDENTITY_RE, "[REDACTED_IDENTITY]", "identities", summary);
|
||||
next = replacePattern(next, SID_RE, "[REDACTED_IDENTITY]", "identities", summary);
|
||||
next = replacePattern(next, MACHINE_ACCOUNT_RE, "[REDACTED_IDENTITY]", "identities", summary);
|
||||
next = replacePattern(next, IPV4_MAPPED_IPV6_RE, "[REDACTED_IP]", "ips", summary, isValidIpv6);
|
||||
next = replacePattern(next, IPV4_RE, "[REDACTED_IP]", "ips", summary, isValidIpv4);
|
||||
next = replacePattern(next, IPV6_RE, "[REDACTED_IP]", "ips", summary, isValidIpv6);
|
||||
return replaceKnownValues(next, known, summary);
|
||||
}
|
||||
|
||||
function redactField(key: string, value: string, known: Map<string, KnownValue>, summary: RedactionSummary): string {
|
||||
const category = fieldCategory(key);
|
||||
if (category && value) {
|
||||
increment(summary, category);
|
||||
return replacementFor(category);
|
||||
}
|
||||
return redactText(value, known, summary);
|
||||
}
|
||||
|
||||
function redactEvent(event: EventItem, known: Map<string, KnownValue>, summary: RedactionSummary): EventItem {
|
||||
const fields = Object.fromEntries(
|
||||
Object.entries(event.fields ?? {}).map(([key, value]) => [key, redactField(key, value, known, summary)]),
|
||||
);
|
||||
let computer = event.computer ?? "";
|
||||
if (computer) {
|
||||
increment(summary, "hosts");
|
||||
computer = "[REDACTED_HOST]";
|
||||
}
|
||||
return { ...event, computer, summary: redactText(event.summary, known, summary), fields };
|
||||
}
|
||||
|
||||
export function redactAnalysis(source: Analysis): RedactionResult {
|
||||
const summary: RedactionSummary = { hosts: 0, identities: 0, emails: 0, ips: 0, paths: 0, sources: source.source ? 1 : 0, total: source.source ? 1 : 0 };
|
||||
const known = collectKnownValues(source);
|
||||
const events = source.events.map((event) => redactEvent(event, known, summary));
|
||||
const incidents = source.incidents.map((incident) => ({
|
||||
...incident,
|
||||
title: redactText(incident.title, known, summary),
|
||||
reasons: incident.reasons.map((reason) => redactText(reason, known, summary)),
|
||||
}));
|
||||
return { analysis: { ...source, source: "[REDACTED_SOURCE].evtx", events, incidents }, summary };
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #07131f;
|
||||
color: #e7eef7;
|
||||
--bg: #07131f;
|
||||
--bg-raised: #0a1b2a;
|
||||
--bg-panel: #0b2233;
|
||||
--bg-soft: #0e2a3e;
|
||||
--line: #21445b;
|
||||
--line-soft: #18364b;
|
||||
--text: #e7eef7;
|
||||
--muted: #9db1c2;
|
||||
--subtle: #728ba0;
|
||||
--blue: #32b6ff;
|
||||
--blue-strong: #168dff;
|
||||
--amber: #ffc342;
|
||||
--red: #ff6870;
|
||||
--green: #4bd985;
|
||||
--focus: #66caff;
|
||||
--shadow-panel: 0 14px 34px rgba(0, 0, 0, .08);
|
||||
--transition-fast: .15s ease;
|
||||
--radius: 8px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-width: 320px; background: radial-gradient(circle at 50% -14%, #102f47 0, var(--bg) 42%, #06101a 100%); font-size: 14px; }
|
||||
button, input, select { font: inherit; }
|
||||
button { color: inherit; }
|
||||
button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; }
|
||||
button:disabled { cursor: not-allowed; opacity: .5; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
.hidden-input { display: none; }
|
||||
|
||||
.event-app-shell { min-height: 100vh; }
|
||||
.event-topbar { min-height: 70px; padding: 0 28px; display: flex; align-items: center; gap: 28px; border-bottom: 1px solid rgba(61, 105, 136, .42); background: rgba(5, 17, 28, .94); position: sticky; top: 0; z-index: 10; backdrop-filter: blur(14px); }
|
||||
.event-brand { display: flex; align-items: center; gap: 10px; min-width: 260px; }
|
||||
.event-logo { width: 42px; height: 42px; display: grid; place-items: center; color: var(--blue); }
|
||||
.event-logo svg { width: 100%; height: 100%; }
|
||||
.event-brand strong { display: block; font-size: 22px; letter-spacing: -.03em; line-height: 1.05; }
|
||||
.event-brand small { display: block; margin-top: 4px; color: var(--muted); font-size: 12px; }
|
||||
.workspace-nav { display: flex; align-self: stretch; align-items: stretch; gap: 4px; overflow-x: auto; scrollbar-width: none; }
|
||||
.workspace-nav::-webkit-scrollbar { display: none; }
|
||||
.workspace-nav button { min-width: 88px; padding: 0 14px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: var(--muted); cursor: pointer; white-space: nowrap; transition: color var(--transition-fast), background var(--transition-fast), border-color var(--transition-fast); }
|
||||
.workspace-nav button svg { width: 17px; height: 17px; }
|
||||
.workspace-nav button:hover { color: var(--text); background: rgba(33, 78, 108, .24); }
|
||||
.workspace-nav button.active { color: var(--blue); border-bottom-color: var(--blue); background: rgba(20, 91, 132, .13); }
|
||||
.instance-status { margin-left: auto; display: flex; align-items: flex-start; gap: 8px; white-space: nowrap; }
|
||||
.instance-status > span { width: 9px; height: 9px; margin-top: 4px; border-radius: 50%; background: var(--green); box-shadow: 0 0 0 3px rgba(75, 217, 133, .1); }
|
||||
.instance-status div { display: flex; flex-direction: column; gap: 3px; }
|
||||
.instance-status b { font-size: 12px; }
|
||||
.instance-status small { color: var(--subtle); font-size: 11px; text-align: right; }
|
||||
|
||||
.event-main { width: min(100%, 1760px); margin: 0 auto; padding: 18px 28px 0; }
|
||||
.global-actions { min-height: 58px; display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 8px 0 16px; border-bottom: 1px solid rgba(39, 77, 101, .48); transition: border-color .15s ease; }
|
||||
.global-actions.dragging { border-color: var(--blue); }
|
||||
.global-actions-secondary { min-height: 50px; padding-bottom: 12px; }
|
||||
.global-actions-secondary .primary-button { border-color: #315773; background: #0d263a; box-shadow: none; color: #dce8f2; }
|
||||
.global-actions-secondary .primary-button:hover { border-color: #4c7898; background: #12334b; }
|
||||
.global-actions-secondary .drop-hint { color: #668096; }
|
||||
.action-buttons { display: flex; align-items: center; gap: 9px; min-width: 0; }
|
||||
.primary-button, .secondary-button { min-height: 38px; padding: 0 15px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; border-radius: 6px; font-weight: 700; cursor: pointer; transition: background var(--transition-fast), border-color var(--transition-fast), transform var(--transition-fast); }
|
||||
.primary-button svg, .secondary-button svg, .reset-button svg, .text-button svg { width: 16px; height: 16px; }
|
||||
.primary-button:hover, .secondary-button:hover, .quiet-button:hover, .reset-button:hover { transform: translateY(-1px); }
|
||||
.primary-button { border: 1px solid #35aaff; background: linear-gradient(180deg, #1b9cff, #137de1); color: #fff; box-shadow: 0 4px 14px rgba(16, 126, 220, .18); }
|
||||
.primary-button:hover { background: linear-gradient(180deg, #31adff, #1689ec); }
|
||||
.secondary-button { border: 1px solid #315773; background: #0d263a; color: #dce8f2; }
|
||||
.secondary-button:hover { border-color: #4c7898; background: #12334b; }
|
||||
.drop-hint { margin-left: 4px; color: var(--subtle); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.context-muted { color: var(--subtle); }
|
||||
.investigation-context { min-height: 46px; margin-top: 4px; padding: 8px 12px; display: flex; align-items: center; gap: 14px; border: 1px solid var(--line-soft); border-radius: 0 0 var(--radius) var(--radius); background: rgba(7, 25, 38, .66); color: var(--muted); font-size: 12px; }
|
||||
.investigation-source { min-width: 0; display: flex; align-items: center; gap: 8px; }
|
||||
.investigation-source > svg { width: 18px; height: 18px; flex: 0 0 auto; color: var(--blue); }
|
||||
.investigation-source span { min-width: 0; display: grid; gap: 2px; }
|
||||
.investigation-source b { max-width: 300px; overflow: hidden; color: var(--text); text-overflow: ellipsis; white-space: nowrap; }
|
||||
.investigation-source small { color: var(--subtle); font-size: 10px; }
|
||||
.context-facts { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
|
||||
.context-facts > span:not(.status-badge) { color: var(--subtle); white-space: nowrap; }
|
||||
.context-incident { min-width: 0; margin-left: auto; overflow: hidden; color: var(--subtle); text-overflow: ellipsis; white-space: nowrap; }
|
||||
.context-incident b { color: var(--text); }
|
||||
.preferences-context { justify-content: space-between; }
|
||||
.preferences-context .context-facts { margin-left: auto; }
|
||||
.status-badge { display: inline-flex; align-items: center; min-height: 23px; padding: 3px 9px; border: 1px solid currentColor; border-radius: 999px; font-size: 11px; font-weight: 700; }
|
||||
.status-complete { color: var(--green); background: rgba(48, 129, 86, .18); }
|
||||
.status-partial { color: var(--amber); background: rgba(157, 102, 14, .2); }
|
||||
.status-failed { color: var(--red); background: rgba(145, 37, 52, .2); }
|
||||
.status-demo { color: var(--blue); background: rgba(35, 120, 170, .18); }
|
||||
.loading-banner, .error-banner { margin: 14px 0 0; padding: 11px 14px; border-radius: var(--radius); display: flex; align-items: center; gap: 10px; }
|
||||
.loading-banner { border: 1px solid #2b6c8d; background: #0c2e40; color: #c9f0ff; }
|
||||
.loading-spinner { width: 15px; height: 15px; border: 2px solid rgba(166, 226, 255, .32); border-top-color: #a6e2ff; border-radius: 50%; animation: spin .8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.error-banner { justify-content: space-between; border: 1px solid #75404f; background: #2a1822; color: #ffd9de; }
|
||||
.error-banner div { display: grid; gap: 3px; }
|
||||
.error-banner span { overflow-wrap: anywhere; }
|
||||
.text-button { border: 0; padding: 4px 0; background: transparent; color: var(--blue); cursor: pointer; font-weight: 700; }
|
||||
.text-button:hover { color: #9bdcff; text-decoration: underline; }
|
||||
|
||||
.partial-warning, .parser-issues { margin-top: 14px; padding: 13px 16px; border-radius: var(--radius); }
|
||||
.partial-warning { border: 1px solid #d08d24; background: linear-gradient(90deg, rgba(77, 43, 9, .94), rgba(51, 33, 12, .76)); color: #ffe5b5; }
|
||||
.notice-title { display: flex; align-items: flex-start; gap: 10px; }
|
||||
.notice-title svg { width: 23px; min-width: 23px; color: var(--amber); }
|
||||
.notice-title div { display: grid; gap: 4px; }
|
||||
.notice-title strong { font-size: 14px; }
|
||||
.notice-title span, .partial-warning p, .partial-warning li, .parser-issues, .parser-issues li { font-size: 12px; line-height: 1.45; }
|
||||
.partial-warning p { margin: 8px 0 4px; }
|
||||
.partial-warning ul, .parser-issues ul { margin: 4px 0 0; padding-left: 24px; }
|
||||
.partial-warning code, .parser-issues code { color: #ffd26c; }
|
||||
.parser-issues { display: grid; gap: 4px; border: 1px solid #2e7185; background: #0b2c3b; color: #c2effb; }
|
||||
.parser-issues code { color: #91e4ff; }
|
||||
|
||||
.workspace-content { padding-top: 22px; }
|
||||
.workspace-header { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; margin-bottom: 22px; }
|
||||
.eyebrow { margin: 0 0 6px; color: var(--blue); font-size: 11px; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; }
|
||||
.workspace-header h1 { margin: 0; color: var(--text); font-size: clamp(25px, 3vw, 34px); line-height: 1.05; letter-spacing: -.035em; }
|
||||
.workspace-description { max-width: 740px; margin: 8px 0 0; color: var(--muted); line-height: 1.5; }
|
||||
.panel { border: 1px solid rgba(44, 85, 111, .72); border-radius: var(--radius); background: linear-gradient(180deg, rgba(10, 31, 47, .97), rgba(7, 24, 37, .97)); box-shadow: var(--shadow-panel); }
|
||||
.panel-header { min-height: 55px; display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 10px 16px; border-bottom: 1px solid var(--line-soft); }
|
||||
.panel-header h2 { margin: 0; font-size: 16px; letter-spacing: -.01em; }
|
||||
.panel-count, .section-count { color: var(--subtle); font-size: 12px; }
|
||||
.section-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; }
|
||||
.section-heading h2 { margin: 0; font-size: 20px; letter-spacing: -.02em; }
|
||||
|
||||
.empty-state { min-height: 360px; padding: 52px 30px; border: 1px dashed #2d5972; border-radius: var(--radius); display: flex; align-items: center; justify-content: center; gap: 22px; background: linear-gradient(140deg, rgba(11, 37, 56, .78), rgba(7, 22, 34, .5)); }
|
||||
.empty-state-mark { width: 60px; height: 60px; display: grid; place-items: center; border: 1px solid #2d7196; border-radius: 50%; color: var(--blue); background: rgba(25, 117, 163, .15); }
|
||||
.empty-state-mark svg { width: 29px; }
|
||||
.empty-state h2 { margin: 0; font-size: 22px; }
|
||||
.empty-state p { max-width: 520px; margin: 8px 0 18px; color: var(--muted); line-height: 1.55; }
|
||||
.empty-actions { display: flex; gap: 9px; }
|
||||
.workflow-steps { margin: 0 0 12px; padding: 0; display: flex; flex-wrap: wrap; gap: 5px 0; list-style: none; color: #b9dce9; font-size: 11px; font-weight: 700; }
|
||||
.workflow-steps li { display: inline-flex; align-items: center; }
|
||||
.workflow-steps li + li::before { content: "→"; margin: 0 7px; color: var(--amber); }
|
||||
.empty-privacy { margin-bottom: 16px !important; color: var(--subtle) !important; font-size: 12px; }
|
||||
.analysis-summary { display: grid; grid-template-columns: minmax(205px, .82fr) minmax(0, 2.18fr); gap: 15px; }
|
||||
.analysis-status-card { min-height: 126px; padding: 19px; display: flex; flex-direction: column; justify-content: center; border: 1px solid var(--line); border-left: 3px solid var(--green); border-radius: var(--radius); background: linear-gradient(180deg, #0d2a40, #091e2f); }
|
||||
.analysis-status-card .eyebrow { margin-bottom: 8px; }
|
||||
.analysis-status-card strong { color: var(--green); font-size: clamp(21px, 2.2vw, 29px); line-height: 1; text-transform: uppercase; }
|
||||
.analysis-status-card p { margin: 8px 0 0; color: var(--muted); font-size: 12px; line-height: 1.4; }
|
||||
.analysis-status-card.status-partial { border-left-color: var(--amber); }
|
||||
.analysis-status-card.status-partial strong { color: var(--amber); }
|
||||
.analysis-status-card.status-failed { border-left-color: var(--red); }
|
||||
.analysis-status-card.status-failed strong { color: var(--red); }
|
||||
.metric-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
|
||||
.metric-card { min-height: 126px; padding: 19px; display: grid; grid-template-columns: 40px 1fr; grid-template-rows: auto auto; align-content: center; gap: 2px 12px; border: 1px solid var(--line); border-radius: var(--radius); background: linear-gradient(180deg, #0d2a40, #091e2f); }
|
||||
.metric-card .metric-icon { grid-row: 1 / 3; width: 35px; align-self: center; color: var(--blue); }
|
||||
.metric-card .metric-icon svg { width: 100%; height: auto; }
|
||||
.metric-card strong { font-size: clamp(22px, 2.4vw, 31px); color: var(--blue); line-height: 1.05; overflow-wrap: anywhere; }
|
||||
.metric-card > span:last-child { color: var(--muted); font-size: 12px; }
|
||||
.metric-card.tone-amber .metric-icon, .metric-card.tone-amber strong { color: var(--amber); }
|
||||
.metric-card.tone-green .metric-icon, .metric-card.tone-green strong { color: var(--green); }
|
||||
.analysis-section { margin-top: 28px; }
|
||||
.incident-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 12px; margin-top: 13px; }
|
||||
.incident-card { position: relative; min-height: 173px; padding: 17px; display: flex; flex-direction: column; align-items: stretch; text-align: left; border: 1px solid #8b671c; border-left: 3px solid var(--amber); border-radius: var(--radius); background: linear-gradient(155deg, #0d283a, #081b2a); cursor: pointer; transition: border-color .15s ease, background .15s ease, transform .15s ease; }
|
||||
.incident-card:hover { transform: translateY(-2px); background: linear-gradient(155deg, #113149, #0b2133); }
|
||||
.incident-card.selected { box-shadow: 0 0 0 2px rgba(50, 182, 255, .62); border-color: var(--blue); }
|
||||
.incident-card.severity-high { border-color: #9b3d4b; border-left-color: var(--red); }
|
||||
.incident-card-heading { min-width: 0; display: grid; grid-template-columns: 23px 1fr auto; align-items: start; gap: 8px; }
|
||||
.incident-icon { color: var(--amber); }
|
||||
.incident-icon svg { width: 21px; }
|
||||
.severity-high .incident-icon { color: var(--red); }
|
||||
.incident-card-heading strong { min-width: 0; font-size: 14px; line-height: 1.3; }
|
||||
.severity-pill { align-self: start; padding: 3px 8px; border: 1px solid currentColor; border-radius: 999px; color: var(--amber); background: rgba(95, 66, 7, .35); font-size: 10px; font-weight: 700; }
|
||||
.severity-high .severity-pill { color: var(--red); background: rgba(100, 23, 35, .35); }
|
||||
.incident-card p { min-height: 37px; margin: 13px 0 11px; color: var(--muted); font-size: 12px; line-height: 1.5; }
|
||||
.incident-card footer { display: flex; justify-content: space-between; gap: 10px; margin-top: auto; color: var(--subtle); font-size: 11px; }
|
||||
.incident-card footer span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.incident-card-action { margin-top: 13px; color: var(--blue); font-size: 12px; font-weight: 700; display: inline-flex; align-items: center; gap: 5px; }
|
||||
.incident-card-action svg { width: 14px; height: 14px; }
|
||||
.analysis-note, .hunting-hint { margin-top: 20px; padding: 14px 16px; display: flex; align-items: flex-start; gap: 11px; border-left: 3px solid #2d83af; background: rgba(10, 43, 64, .55); color: var(--muted); }
|
||||
.analysis-note > svg, .hunting-hint > svg { width: 19px; height: 19px; flex: 0 0 auto; color: var(--blue); }
|
||||
.analysis-note strong { color: var(--text); }
|
||||
.analysis-note p { margin: 4px 0 0; line-height: 1.45; }
|
||||
|
||||
.incident-context { margin-bottom: 13px; padding: 12px 14px; display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; border: 1px solid var(--line); border-radius: var(--radius); background: rgba(9, 30, 45, .7); }
|
||||
.incident-context label, .filter-secondary label, .hunting-facets label { display: grid; gap: 5px; min-width: 0; color: var(--subtle); font-size: 11px; }
|
||||
.incident-context select { min-width: 280px; }
|
||||
.incident-context-detail { display: flex; align-items: center; gap: 12px; color: var(--muted); font-size: 12px; }
|
||||
.incident-focus-chip { padding: 5px 9px; border: 1px solid #3d5d71; border-radius: 999px; color: var(--muted); }
|
||||
.incident-focus-chip.active { border-color: var(--blue); color: var(--blue); background: rgba(27, 135, 194, .15); }
|
||||
|
||||
.filter-toolbar { margin-bottom: 14px; padding: 14px; border: 1px solid var(--line); border-radius: var(--radius); background: rgba(8, 27, 41, .72); }
|
||||
.filter-primary { display: flex; align-items: center; gap: 14px; }
|
||||
.search-field { height: 42px; min-width: 0; display: flex; align-items: center; gap: 9px; padding: 0 12px; border: 1px solid #2c536b; border-radius: 6px; background: #071b2a; }
|
||||
.search-field:focus-within { border-color: var(--blue); box-shadow: 0 0 0 2px rgba(50, 182, 255, .12); }
|
||||
.search-field svg { width: 18px; flex: 0 0 auto; color: var(--muted); }
|
||||
.search-field input { width: 100%; min-width: 0; border: 0; outline: 0; background: transparent; color: var(--text); }
|
||||
.search-field input::placeholder { color: #7992a7; }
|
||||
.filter-primary .search-field { flex: 1; }
|
||||
.result-count { color: var(--subtle); font-size: 12px; white-space: nowrap; }
|
||||
.result-count strong { color: var(--text); }
|
||||
.filter-secondary { display: grid; grid-template-columns: minmax(145px, .9fr) minmax(125px, .8fr) minmax(200px, 1.4fr) minmax(140px, .9fr); gap: 10px; margin-top: 12px; }
|
||||
.filter-secondary select, .hunting-facets select, .hunting-facets input, .incident-context select { width: 100%; height: 36px; min-width: 0; padding: 0 10px; border: 1px solid #2b5068; border-radius: 5px; outline: 0; background: #0a2234; color: var(--text); }
|
||||
.filter-secondary select:focus, .hunting-facets select:focus, .hunting-facets input:focus, .incident-context select:focus { border-color: var(--blue); }
|
||||
.filter-actions { margin-top: 12px; display: flex; align-items: center; flex-wrap: wrap; gap: 15px; }
|
||||
.toggle-control { display: inline-flex; align-items: center; gap: 8px; color: var(--muted); font-size: 12px; cursor: pointer; }
|
||||
.toggle-control input { position: absolute; opacity: 0; width: 1px; height: 1px; }
|
||||
.toggle-track { pointer-events: none; }
|
||||
.toggle-control input:focus-visible + .toggle-track { outline: 2px solid #66caff; outline-offset: 3px; }
|
||||
.toggle-track { position: relative; width: 32px; height: 18px; flex: 0 0 auto; border-radius: 20px; background: #4a6172; transition: background .15s ease; }
|
||||
.toggle-track::after { content: ""; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; border-radius: 50%; background: #fff; transition: transform .15s ease; }
|
||||
.toggle-control input:checked + .toggle-track { background: var(--blue-strong); }
|
||||
.toggle-control input:checked + .toggle-track::after { transform: translateX(14px); }
|
||||
.reset-button, .quiet-button { min-height: 34px; padding: 0 10px; border: 1px solid #315773; border-radius: 5px; background: #0c2639; color: var(--muted); cursor: pointer; font-size: 12px; }
|
||||
.reset-button { margin-left: auto; color: var(--text); }
|
||||
.filter-chips { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 12px; }
|
||||
.filter-chips span { padding: 4px 8px; border: 1px solid #306887; border-radius: 999px; color: #a8daf3; background: rgba(23, 112, 156, .15); font-size: 11px; }
|
||||
.timeline-layout { display: grid; grid-template-columns: minmax(0, 1.65fr) minmax(360px, .95fr); gap: 14px; min-height: 590px; }
|
||||
.timeline-panel, .inspector-panel { min-width: 0; overflow: hidden; }
|
||||
.date-band { min-height: 34px; padding: 9px 16px; border-bottom: 1px solid var(--line-soft); color: var(--muted); font-size: 12px; font-weight: 700; }
|
||||
.event-list { height: 533px; overflow: auto; contain: strict; }
|
||||
.event-list-inner { position: relative; min-height: 100%; }
|
||||
.event-list-window { position: absolute; inset: 0 0 auto; }
|
||||
.event-row { width: 100%; min-height: 48px; padding: 0 13px 0 0; display: grid; grid-template-columns: 30px 86px 58px minmax(180px, 1fr) minmax(82px, 115px) 68px; align-items: center; text-align: left; border: 0; border-bottom: 1px solid var(--line-soft); background: transparent; cursor: pointer; overflow: hidden; }
|
||||
.event-row:hover { background: rgba(26, 77, 106, .3); }
|
||||
.event-row.active { background: linear-gradient(90deg, rgba(22, 107, 157, .72), rgba(12, 48, 74, .72)); box-shadow: inset 3px 0 var(--blue); }
|
||||
.event-row time, .event-id, .event-host, .event-level { font-size: 11px; }
|
||||
.event-row time { color: #b8c8d5; white-space: nowrap; }
|
||||
.event-id { color: var(--text); }
|
||||
.event-copy { min-width: 0; display: flex; flex-direction: column; gap: 3px; }
|
||||
.event-copy strong, .event-copy span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.event-copy strong { color: #d6e3ed; font-size: 12px; font-weight: 650; }
|
||||
.event-copy span { color: var(--muted); font-size: 12px; }
|
||||
.event-host { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--subtle); }
|
||||
.event-level { justify-self: end; padding: 4px 7px; border: 1px solid currentColor; border-radius: 4px; text-transform: capitalize; }
|
||||
.level-error, .level-critical { color: var(--red); background: rgba(113, 26, 39, .35); }
|
||||
.level-warning { color: var(--amber); background: rgba(102, 74, 7, .33); }
|
||||
.level-information { color: #68caff; background: rgba(28, 96, 137, .35); }
|
||||
.event-rail { position: relative; height: 100%; }
|
||||
.event-rail::before { content: ""; position: absolute; left: 17px; top: 0; bottom: 0; width: 2px; background: #2e8cbd; }
|
||||
.event-rail > span { position: absolute; left: 12px; top: calc(50% - 5px); width: 11px; height: 11px; border: 2px solid #07131f; border-radius: 50%; background: #42b6f2; }
|
||||
.event-rail.rail-error::before, .event-rail.rail-critical::before { background: var(--red); }
|
||||
.event-rail.rail-error > span, .event-rail.rail-critical > span { background: var(--red); }
|
||||
.event-rail.rail-warning::before { background: #d7a821; }
|
||||
.event-rail.rail-warning > span { background: var(--amber); }
|
||||
.empty-list { display: grid; place-items: center; min-height: 260px; color: var(--muted); }
|
||||
.empty-list p { padding: 24px; text-align: center; line-height: 1.5; }
|
||||
|
||||
.inspector-panel { display: flex; flex-direction: column; }
|
||||
.inspector-navigation { display: flex; align-items: center; gap: 8px; }
|
||||
.inspector-navigation button { width: 32px; height: 30px; border: 1px solid #315773; border-radius: 5px; background: #0d2a3f; cursor: pointer; font-size: 20px; line-height: 1; }
|
||||
.inspector-navigation button svg { width: 16px; height: 16px; }
|
||||
.inspector-navigation span { min-width: 64px; color: var(--muted); font-size: 11px; text-align: center; }
|
||||
.inspector-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 16px; }
|
||||
.inspector-title > div { min-width: 0; }
|
||||
.inspector-title h3 { margin: 0 0 5px; overflow-wrap: anywhere; font-size: 16px; line-height: 1.3; }
|
||||
.inspector-title p { margin: 0; color: var(--muted); font-size: 12px; }
|
||||
.inspector-actions { padding: 0 16px 10px; }
|
||||
.inspector-tabs { min-height: 42px; padding: 0 10px; display: grid; grid-template-columns: repeat(4, 1fr); border-top: 1px solid var(--line-soft); border-bottom: 1px solid var(--line-soft); }
|
||||
.inspector-tabs button { border: 0; border-bottom: 3px solid transparent; background: transparent; color: var(--muted); cursor: pointer; font-size: 11px; }
|
||||
.inspector-tabs button:hover { color: var(--text); }
|
||||
.inspector-tabs button.active { border-bottom-color: var(--blue); color: var(--blue); }
|
||||
.inspector-tabs button[aria-selected="true"] { font-weight: 700; }
|
||||
.inspector-content { padding: 14px 16px; overflow: auto; }
|
||||
.copy-row { display: flex; justify-content: flex-end; gap: 7px; margin-bottom: 11px; }
|
||||
.quiet-button { min-height: 29px; padding: 0 8px; font-size: 11px; }
|
||||
.fact-grid { margin: 0; display: grid; grid-template-columns: minmax(106px, .9fr) minmax(0, 1.5fr); }
|
||||
.fact-grid > div { display: contents; }
|
||||
.fact-grid dt, .fact-grid dd { min-width: 0; padding: 6px 0; border-bottom: 1px solid rgba(35, 69, 91, .55); font-size: 12px; line-height: 1.4; }
|
||||
.fact-grid dt { color: var(--subtle); }
|
||||
.fact-grid dd { margin: 0; overflow-wrap: anywhere; color: var(--text); }
|
||||
.level-dot { display: inline-block; width: 8px; height: 8px; margin-right: 7px; border-radius: 50%; background: var(--red); }
|
||||
.level-dot.warning { background: var(--amber); }
|
||||
.level-dot.information { background: #5fc4f9; }
|
||||
.normalized-fields { margin-top: 16px; border: 1px solid #2c526a; border-radius: 6px; overflow: hidden; background: #071a28; }
|
||||
.normalized-fields > header { min-height: 38px; padding: 0 11px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line-soft); color: var(--text); font-size: 12px; }
|
||||
.normalized-fields > header span { color: var(--subtle); }
|
||||
.normalized-fields > div { padding: 9px 11px; }
|
||||
.normalized-fields p { margin: 0; padding: 5px 0; display: grid; grid-template-columns: minmax(100px, .8fr) minmax(0, 1.2fr); gap: 10px; border-bottom: 1px solid rgba(35, 69, 91, .35); font-size: 11px; }
|
||||
.normalized-fields p:last-child { border-bottom: 0; }
|
||||
.normalized-fields code { color: #a9c4d7; overflow-wrap: anywhere; }
|
||||
.normalized-fields p span { color: var(--text); overflow-wrap: anywhere; }
|
||||
.muted-copy { color: var(--muted); font-size: 12px; line-height: 1.5; }
|
||||
.normalized-fields > .muted-copy { margin: 12px; }
|
||||
.inspector-raw { flex: 1; min-height: 320px; margin: 14px; padding: 13px; overflow: auto; border: 1px solid #2a4d64; border-radius: 6px; background: #04131e; color: #c7e2f1; font: 11px/1.55 ui-monospace, SFMono-Regular, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.inspector-placeholder { padding: 28px 18px; color: var(--muted); line-height: 1.5; }
|
||||
.inspector-placeholder p { margin: 0 0 5px; color: var(--text); font-weight: 700; }
|
||||
.related-events { display: grid; gap: 7px; }
|
||||
.related-event { min-width: 0; padding: 9px 10px; display: grid; grid-template-columns: 75px 45px minmax(0, 1fr); gap: 8px; border: 1px solid var(--line-soft); border-radius: 5px; background: transparent; color: var(--muted); text-align: left; cursor: pointer; font-size: 11px; }
|
||||
.related-event:hover, .related-event.active { border-color: var(--blue); background: rgba(24, 107, 150, .22); }
|
||||
.related-event strong { color: var(--text); }
|
||||
.related-event span:last-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.insight-copy { color: var(--muted); font-size: 12px; line-height: 1.55; }
|
||||
.insight-copy p { margin: 0 0 12px; }
|
||||
.insight-copy strong { color: var(--text); }
|
||||
|
||||
.hunting-query { padding: 16px; margin-bottom: 14px; }
|
||||
.hunting-query-main { display: flex; align-items: center; gap: 14px; }
|
||||
.search-field.large { height: 48px; flex: 1; }
|
||||
.search-field.large input { font-size: 15px; }
|
||||
.hunting-facets { display: grid; grid-template-columns: minmax(100px, .6fr) minmax(150px, 1.15fr) minmax(130px, .9fr) minmax(120px, .8fr) minmax(150px, 1fr) minmax(130px, .8fr); gap: 10px; margin-top: 14px; align-items: end; }
|
||||
.hunting-facets input { height: 36px; }
|
||||
.hunting-facets .facet-toggle { min-height: 36px; align-self: end; }
|
||||
.hunting-facets .facet-toggle { grid-column: span 2; }
|
||||
.hunting-facets .reset-button { grid-column: 6; margin: 0; height: 36px; justify-self: end; white-space: nowrap; }
|
||||
.hunting-layout { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(360px, 1fr); gap: 14px; min-height: 590px; }
|
||||
.hunting-results { min-width: 0; overflow: hidden; }
|
||||
.hunting-result-list { max-height: 590px; overflow: auto; }
|
||||
.hunting-result { width: 100%; min-height: 62px; padding: 9px 13px; display: grid; grid-template-columns: 96px 48px minmax(0, 1fr) minmax(85px, 110px) 12px; gap: 10px; align-items: center; border: 0; border-bottom: 1px solid var(--line-soft); background: transparent; text-align: left; cursor: pointer; }
|
||||
.hunting-result:hover, .hunting-result.active { background: rgba(24, 107, 150, .25); }
|
||||
.hunting-result .result-time, .result-meta { color: var(--subtle); font-size: 11px; line-height: 1.4; }
|
||||
.hunting-result > strong { color: var(--text); font-size: 12px; }
|
||||
.result-main { min-width: 0; display: grid; gap: 3px; }
|
||||
.result-main b, .result-main span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.result-main b { color: #d6e3ed; font-size: 12px; }
|
||||
.result-main span { color: var(--muted); font-size: 11px; }
|
||||
.result-meta { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.level-text { font-size: 10px; font-weight: 700; }
|
||||
.membership-dot { width: 9px; height: 9px; border: 1px solid #587080; border-radius: 50%; }
|
||||
.membership-dot.known { border-color: var(--blue); background: var(--blue); box-shadow: 0 0 0 3px rgba(50, 182, 255, .12); }
|
||||
.quiet-empty { min-height: 180px; padding: 30px 22px; color: var(--muted); }
|
||||
.quiet-empty h3 { margin: 0; color: var(--text); font-size: 16px; }
|
||||
.quiet-empty p { margin: 8px 0 0; line-height: 1.5; }
|
||||
.hunting-hint { margin-bottom: 0; font-size: 12px; line-height: 1.5; }
|
||||
|
||||
.report-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||
.export-readiness { margin: 0 0 14px; padding: 12px 14px; display: flex; align-items: center; gap: 10px; border: 1px solid var(--line); border-radius: var(--radius); background: rgba(10, 34, 49, .7); }
|
||||
.export-readiness svg { width: 22px; height: 22px; flex: 0 0 auto; }
|
||||
.export-readiness div { display: grid; gap: 3px; }
|
||||
.export-readiness strong { color: var(--text); font-size: 13px; }
|
||||
.export-readiness span { color: var(--muted); font-size: 12px; line-height: 1.4; }
|
||||
.export-readiness.ready { border-color: rgba(75, 217, 133, .55); }
|
||||
.export-readiness.ready svg { color: var(--green); }
|
||||
.export-readiness.caution, .export-readiness.partial { border-color: rgba(255, 195, 66, .62); }
|
||||
.export-readiness.caution svg, .export-readiness.partial svg { color: var(--amber); }
|
||||
.report-card { min-width: 0; padding: 18px; }
|
||||
.report-card .section-heading { align-items: center; }
|
||||
.report-card p { color: var(--muted); line-height: 1.5; }
|
||||
.report-facts { margin: 16px 0 0; display: grid; grid-template-columns: minmax(100px, .8fr) minmax(0, 1.2fr); }
|
||||
.report-facts > div { display: contents; }
|
||||
.report-facts dt, .report-facts dd { padding: 8px 0; border-bottom: 1px solid var(--line-soft); font-size: 12px; }
|
||||
.report-facts dt { color: var(--subtle); }
|
||||
.report-facts dd { min-width: 0; margin: 0; overflow-wrap: anywhere; color: var(--text); }
|
||||
.completeness-summary { margin-top: 17px; padding: 13px; display: grid; gap: 4px; border: 1px solid #2a536a; border-radius: 6px; background: rgba(12, 50, 72, .45); }
|
||||
.completeness-summary strong { color: var(--text); }
|
||||
.completeness-summary span { color: var(--muted); font-size: 12px; }
|
||||
.warning-copy { color: var(--amber) !important; font-size: 12px; }
|
||||
.issue-list { margin: 15px 0 0; padding: 0; list-style: none; display: grid; gap: 8px; }
|
||||
.issue-list li { display: grid; gap: 3px; padding: 8px 10px; border-left: 2px solid var(--amber); background: rgba(77, 51, 8, .23); font-size: 12px; }
|
||||
.issue-list code { color: #ffd36b; }
|
||||
.issue-list span { color: var(--muted); }
|
||||
.privacy-card, .export-card { min-height: 215px; }
|
||||
.privacy-card > p, .export-card > p { font-size: 13px; }
|
||||
.redaction-stats { margin-top: 18px; display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.redaction-stats span { min-width: 75px; padding: 9px 10px; display: grid; gap: 3px; border: 1px solid #2d5872; border-radius: 6px; background: rgba(10, 42, 62, .62); color: var(--subtle); font-size: 10px; text-transform: uppercase; letter-spacing: .05em; }
|
||||
.redaction-stats strong { color: var(--blue); font-size: 18px; letter-spacing: 0; }
|
||||
.raw-export-warning { margin-top: 19px; padding: 11px; display: flex; align-items: center; gap: 9px; border: 1px solid #875d1c; border-radius: 6px; color: var(--amber); font-size: 12px; }
|
||||
.raw-export-warning svg { width: 20px; flex: 0 0 auto; }
|
||||
.export-actions { display: flex; gap: 9px; margin-top: 22px; }
|
||||
|
||||
.settings-header-status { display: grid; justify-items: end; gap: 4px; min-height: 30px; }
|
||||
.settings-saved-automatically { display: inline-flex; align-items: center; gap: 6px; color: var(--green); font-size: 12px; font-weight: 700; white-space: nowrap; }
|
||||
.settings-saved-automatically svg { width: 16px; height: 16px; }
|
||||
.settings-save-feedback { min-height: 15px; color: var(--subtle); font-size: 11px; }
|
||||
.settings-layout { max-width: 1260px; display: grid; grid-template-columns: minmax(0, 1.65fr) minmax(280px, .85fr); gap: 16px; align-items: start; }
|
||||
.settings-preferences { display: grid; gap: 16px; }
|
||||
.settings-section { padding: 0 20px; overflow: hidden; }
|
||||
.settings-section > header, .settings-privacy-panel > header { display: flex; align-items: flex-start; gap: 11px; padding: 18px 0 16px; border-bottom: 1px solid var(--line-soft); }
|
||||
.settings-section > header > svg, .settings-privacy-panel > header > svg { width: 20px; height: 20px; flex: 0 0 auto; color: var(--blue); }
|
||||
.settings-section h2, .settings-privacy-panel h2 { margin: 0; font-size: 16px; letter-spacing: -.01em; }
|
||||
.settings-section header p { margin: 5px 0 0; color: var(--muted); font-size: 12px; line-height: 1.45; }
|
||||
.settings-section fieldset { margin: 0; padding: 18px 0; border: 0; }
|
||||
.settings-section fieldset + fieldset { border-top: 1px solid var(--line-soft); }
|
||||
.settings-section legend { padding: 0; color: var(--text); font-size: 13px; font-weight: 750; }
|
||||
.preference-option-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-top: 11px; }
|
||||
.preference-option { min-height: 80px; padding: 13px; display: grid; grid-template-columns: 17px minmax(0, 1fr); align-items: start; gap: 10px; border: 1px solid #294f67; border-radius: 7px; background: rgba(8, 29, 43, .62); cursor: pointer; transition: border-color var(--transition-fast), background var(--transition-fast), box-shadow var(--transition-fast); }
|
||||
.preference-option:hover { border-color: #437895; background: rgba(15, 54, 76, .68); }
|
||||
.preference-option.selected { border-color: var(--blue); background: linear-gradient(145deg, rgba(21, 93, 130, .42), rgba(10, 41, 59, .66)); box-shadow: inset 0 0 0 1px rgba(50, 182, 255, .18); }
|
||||
.preference-option:has(input:focus-visible) { outline: 2px solid var(--focus); outline-offset: 3px; }
|
||||
.preference-option-indicator { width: 16px; height: 16px; margin-top: 1px; border: 1px solid #668398; border-radius: 50%; background: #071b2a; }
|
||||
.preference-option.selected .preference-option-indicator { border: 4px solid var(--blue-strong); background: #d7f4ff; }
|
||||
.preference-option > span:last-child { display: grid; gap: 4px; }
|
||||
.preference-option strong { font-size: 13px; }
|
||||
.preference-option small { color: var(--muted); font-size: 12px; line-height: 1.35; }
|
||||
.preference-rows { padding: 4px 0; }
|
||||
.preference-row { min-height: 76px; padding: 13px 0; display: grid; grid-template-columns: minmax(0, 1fr) 196px; align-items: center; gap: 18px; border-bottom: 1px solid var(--line-soft); }
|
||||
.preference-row:last-child { border-bottom: 0; }
|
||||
.preference-row > span:first-child { display: grid; gap: 4px; min-width: 0; }
|
||||
.preference-row strong { font-size: 13px; }
|
||||
.preference-row small { color: var(--muted); font-size: 12px; line-height: 1.4; }
|
||||
.preference-row select { width: 100%; height: 36px; min-width: 0; padding: 0 10px; border: 1px solid #2b5068; border-radius: 5px; outline: 0; background: #0a2234; color: var(--text); }
|
||||
.preference-row select:focus { border-color: var(--blue); }
|
||||
.preference-switch { justify-self: end; display: inline-flex; align-items: center; gap: 8px; cursor: pointer; }
|
||||
.preference-switch:has(input:focus-visible) { outline: 2px solid var(--focus); outline-offset: 3px; border-radius: 999px; }
|
||||
.preference-switch-track { position: relative; width: 42px; height: 23px; border-radius: 999px; background: #4a6172; transition: background var(--transition-fast); }
|
||||
.preference-switch-track > span { position: absolute; top: 3px; left: 3px; width: 17px; height: 17px; border-radius: 50%; background: #f2f8fb; transition: transform var(--transition-fast); }
|
||||
.preference-switch input:checked + .preference-switch-track { background: var(--blue-strong); }
|
||||
.preference-switch input:checked + .preference-switch-track > span { transform: translateX(19px); }
|
||||
.preference-switch b { min-width: 24px; color: var(--blue); font-size: 11px; text-align: right; text-transform: uppercase; }
|
||||
.settings-privacy-panel { padding: 0 20px 20px; }
|
||||
.settings-privacy-panel > header { padding-bottom: 14px; }
|
||||
.settings-privacy-panel .eyebrow { margin-bottom: 4px; font-size: 10px; }
|
||||
.privacy-lead { margin: 16px 0; color: var(--muted); font-size: 13px; line-height: 1.5; }
|
||||
.privacy-facts { display: grid; gap: 12px; }
|
||||
.privacy-facts section { display: grid; gap: 5px; padding: 11px 12px; border-left: 2px solid #2e779c; background: rgba(11, 43, 62, .42); }
|
||||
.privacy-facts strong { color: var(--text); font-size: 12px; }
|
||||
.privacy-facts span, .privacy-facts li { color: var(--muted); font-size: 12px; line-height: 1.45; }
|
||||
.privacy-facts ul { margin: 0; padding-left: 17px; }
|
||||
.settings-technical-details { margin-top: 16px; color: var(--muted); font-size: 12px; }
|
||||
.settings-technical-details summary { color: #b7d9e8; cursor: pointer; font-weight: 700; }
|
||||
.settings-technical-details p { margin: 10px 0 5px; line-height: 1.45; }
|
||||
.settings-technical-details code { color: #a9dfff; overflow-wrap: anywhere; }
|
||||
.settings-reset { width: 100%; margin-top: 18px; }
|
||||
|
||||
.event-statusbar { min-height: 38px; margin: 26px -28px 0; padding: 0 28px; display: flex; align-items: center; gap: 24px; border-top: 1px solid rgba(38, 71, 91, .65); color: var(--subtle); font-size: 11px; }
|
||||
.event-statusbar > span { display: inline-flex; align-items: center; gap: 5px; min-width: 0; }
|
||||
.event-statusbar svg { width: 14px; height: 14px; }
|
||||
.event-statusbar > span:last-child { margin-left: auto; }
|
||||
.event-statusbar .complete { color: var(--green); }
|
||||
.event-statusbar .partial { color: var(--amber); }
|
||||
.status-dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; box-shadow: 0 0 0 2px color-mix(in srgb, currentColor 18%, transparent); }
|
||||
|
||||
.density-compact .event-row { font-size: 12px; }
|
||||
.density-compact .event-list { height: 560px; }
|
||||
.density-compact .metric-card { min-height: 110px; }
|
||||
|
||||
@media (max-width: 1250px) {
|
||||
.event-topbar { gap: 16px; padding-inline: 20px; }
|
||||
.event-brand { min-width: 220px; }
|
||||
.workspace-nav button { min-width: 74px; padding-inline: 9px; }
|
||||
.event-main { padding-inline: 20px; }
|
||||
.event-statusbar { margin-inline: -20px; padding-inline: 20px; }
|
||||
.timeline-layout, .hunting-layout { grid-template-columns: minmax(0, 1.4fr) minmax(330px, 1fr); }
|
||||
.hunting-facets { grid-template-columns: repeat(3, minmax(120px, 1fr)); }
|
||||
.hunting-facets .facet-toggle { grid-column: span 2; }
|
||||
.hunting-facets .reset-button { grid-column: auto; justify-self: start; }
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.event-topbar { flex-wrap: wrap; min-height: 110px; padding-top: 10px; padding-bottom: 0; }
|
||||
.event-brand { flex: 1; }
|
||||
.instance-status { margin-left: 0; }
|
||||
.workspace-nav { order: 3; width: 100%; min-height: 41px; }
|
||||
.workspace-nav button { min-width: 100px; }
|
||||
.global-actions { align-items: flex-start; flex-direction: column; gap: 10px; }
|
||||
.investigation-context { flex-wrap: wrap; }
|
||||
.context-incident { width: 100%; margin-left: 0; }
|
||||
.analysis-summary { grid-template-columns: 1fr; }
|
||||
.timeline-layout, .hunting-layout { grid-template-columns: 1fr; }
|
||||
.inspector-panel { min-height: 520px; }
|
||||
.hunting-layout .inspector-panel { min-height: 520px; }
|
||||
.report-grid { grid-template-columns: 1fr; }
|
||||
.settings-layout { grid-template-columns: 1fr; }
|
||||
.settings-privacy-panel { max-width: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
body { font-size: 13px; }
|
||||
.event-topbar { min-height: 105px; padding-inline: 12px; }
|
||||
.event-brand { min-width: 0; }
|
||||
.event-logo { width: 36px; height: 36px; }
|
||||
.event-brand strong { font-size: 19px; }
|
||||
.event-brand small { font-size: 10px; }
|
||||
.instance-status b { font-size: 11px; }
|
||||
.workspace-nav button { min-width: 86px; padding-inline: 8px; font-size: 12px; }
|
||||
.event-main { padding: 14px 12px 0; }
|
||||
.drop-hint { display: none; }
|
||||
.investigation-context { margin-top: 0; padding: 9px 10px; gap: 9px; }
|
||||
.investigation-source { width: 100%; }
|
||||
.investigation-source b { max-width: calc(100vw - 78px); }
|
||||
.context-facts { gap: 8px; }
|
||||
.context-incident { max-width: 100%; }
|
||||
.workspace-header { align-items: flex-start; flex-direction: column; gap: 8px; margin-bottom: 16px; }
|
||||
.workspace-header h1 { font-size: 27px; }
|
||||
.workspace-description { font-size: 13px; }
|
||||
.empty-state { min-height: 300px; padding: 35px 20px; align-items: flex-start; flex-direction: column; }
|
||||
.workflow-steps { font-size: 10px; }
|
||||
.workflow-steps li + li::before { margin-inline: 5px; }
|
||||
.metric-grid { grid-template-columns: 1fr; }
|
||||
.metric-card { min-height: 90px; }
|
||||
.incident-grid { grid-template-columns: 1fr; }
|
||||
.incident-context { align-items: stretch; flex-direction: column; }
|
||||
.incident-context select { min-width: 0; width: 100%; }
|
||||
.incident-context-detail { justify-content: space-between; flex-wrap: wrap; }
|
||||
.filter-primary { align-items: stretch; flex-direction: column; gap: 9px; }
|
||||
.filter-secondary { grid-template-columns: 1fr 1fr; }
|
||||
.filter-secondary .provider-filter { grid-column: 1 / -1; }
|
||||
.filter-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.filter-actions .reset-button { margin-left: 0; grid-column: 1 / -1; justify-self: start; }
|
||||
.filter-chips { max-height: 70px; overflow: auto; }
|
||||
.event-list { height: 480px; }
|
||||
.event-row { grid-template-columns: 25px 67px 43px minmax(110px, 1fr) 60px; padding-right: 9px; }
|
||||
.event-row .event-host { display: none; }
|
||||
.event-row time, .event-copy strong, .event-copy span { font-size: 11px; }
|
||||
.event-level { padding: 3px 5px; font-size: 9px; }
|
||||
.inspector-panel { min-height: 490px; }
|
||||
.inspector-tabs { padding-inline: 3px; }
|
||||
.inspector-tabs button { font-size: 10px; }
|
||||
.hunting-query-main { align-items: stretch; flex-direction: column; }
|
||||
.hunting-facets { grid-template-columns: 1fr 1fr; }
|
||||
.hunting-facets .facet-toggle { grid-column: 1 / -1; }
|
||||
.hunting-facets .reset-button { grid-column: 1 / -1; }
|
||||
.hunting-result { grid-template-columns: 72px 38px minmax(0, 1fr) 10px; gap: 7px; }
|
||||
.hunting-result .result-meta { display: none; }
|
||||
.settings-header-status { justify-items: start; }
|
||||
.settings-section, .settings-privacy-panel { padding-inline: 14px; }
|
||||
.preference-option-grid { grid-template-columns: 1fr; }
|
||||
.preference-row { grid-template-columns: 1fr; gap: 10px; }
|
||||
.preference-row select { max-width: none; }
|
||||
.preference-switch { justify-self: start; }
|
||||
.preferences-context .context-facts { width: 100%; margin-left: 0; }
|
||||
.export-actions { flex-direction: column; }
|
||||
.event-statusbar { margin-inline: -12px; padding-inline: 12px; gap: 10px; font-size: 10px; }
|
||||
}
|
||||
|
||||
@media (min-width: 1600px) {
|
||||
.event-main { padding-inline: 40px; }
|
||||
.event-statusbar { margin-inline: -40px; padding-inline: 40px; }
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
export type EventItem = {
|
||||
id: string;
|
||||
timestamp: string | null;
|
||||
timestamp_source?: "event" | "record" | "missing" | "invalid";
|
||||
timestamp_valid?: boolean;
|
||||
provider: string;
|
||||
event_id: number | null;
|
||||
level: string;
|
||||
channel?: string;
|
||||
computer?: string;
|
||||
category: string;
|
||||
summary: string;
|
||||
fields?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type Incident = {
|
||||
id: string;
|
||||
rule_id: string;
|
||||
start: string | null;
|
||||
end: string | null;
|
||||
title: string;
|
||||
category: string;
|
||||
severity: string;
|
||||
reasons: string[];
|
||||
evidence_event_ids: string[];
|
||||
original_count: number;
|
||||
compressed_count: number;
|
||||
};
|
||||
|
||||
export type ParseStats = {
|
||||
parser: string;
|
||||
status: "complete" | "partial" | "failed";
|
||||
parsed_events: number;
|
||||
records_seen: number;
|
||||
skipped_records: number;
|
||||
issue_count: number;
|
||||
truncated: boolean;
|
||||
truncation_reason?: string | null;
|
||||
duration_ms: number;
|
||||
issues: ParseIssue[];
|
||||
};
|
||||
|
||||
export type ParseIssue = {
|
||||
code: string;
|
||||
record_id?: number | null;
|
||||
message: string;
|
||||
skipped_record?: boolean;
|
||||
};
|
||||
|
||||
export type Analysis = {
|
||||
source: string;
|
||||
events: EventItem[];
|
||||
incidents: Incident[];
|
||||
parse?: ParseStats | null;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Analysis, Incident } from "../types";
|
||||
import { statusLabel } from "../app/format";
|
||||
import type { TimestampDisplay } from "../app/model";
|
||||
import { Icon } from "../components/icons";
|
||||
import { EmptyState, IncidentCard, MetricCard, WorkspaceHeader } from "../components/states";
|
||||
|
||||
export function AnalysisView({ data, timestampDisplay, activeIncidentId, onOpen, onOpenFile, onDemo }: { data: Analysis | null; timestampDisplay: TimestampDisplay; activeIncidentId: string; onOpen: (incident: Incident) => void; onOpenFile: () => void; onDemo: () => void }) {
|
||||
if (!data) return <><WorkspaceHeader eyebrow="Workspace 01" title="Analysis" description="A compact overview of the current event-log reconstruction." /><EmptyState onOpen={onOpenFile} onDemo={onDemo} /></>;
|
||||
const statusDetail = data.parse ? `${data.parse.skipped_records} skipped · ${data.parse.issue_count} parser issue${data.parse.issue_count === 1 ? "" : "s"}` : "Deterministic demo fixture";
|
||||
const sourceMode = data.parse ? "EVTX file" : "Demo fixture";
|
||||
return <><WorkspaceHeader eyebrow="Workspace 01 · Overview" title="Analysis" description="Understand completeness and incident windows before opening the evidence timeline." /><section className="analysis-summary" aria-label="Analysis summary"><div className={`analysis-status-card status-${data.parse?.status ?? "complete"}`}><span className="eyebrow">Analysis status</span><strong>{statusLabel(data)}</strong><p>{statusDetail}</p></div><div className="metric-grid"><MetricCard icon={<Icon name="file" />} value={data.events.length.toLocaleString()} label="events parsed" /><MetricCard icon={<Icon name="warning" />} value={data.incidents.length} label="incident windows" tone={data.incidents.length ? "amber" : "green"} /><MetricCard icon={<Icon name="clock" />} value={sourceMode} label="source mode" /></div></section><section className="analysis-section"><div className="section-heading"><div><p className="eyebrow">Deterministic reconstruction</p><h2>Incident windows</h2></div><span className="section-count">{data.incidents.length} found</span></div>{data.incidents.length ? <div className="incident-grid">{data.incidents.map((incident) => <IncidentCard key={incident.id} incident={incident} timestampDisplay={timestampDisplay} selected={activeIncidentId === incident.id} onOpen={onOpen} />)}</div> : <div className="quiet-empty"><h3>No incident windows found</h3><p>Warning and error evidence remains available in Timeline and Hunting for manual review.</p></div>}</section>{!data.parse ? <section className="analysis-note analysis-note-demo"><Icon name="info" /><div><strong>How to continue</strong><p>Select an incident to open its linked evidence in Timeline, or use Hunting to search the complete normalized event set.</p></div></section> : null}</>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Analysis, EventItem, Incident } from "../types";
|
||||
import { CATEGORIES, LEVELS, WINDOWS, type HuntingState, type InspectorTab, type TimestampDisplay } from "../app/model";
|
||||
import { formatClock, formatDate, levelLabel } from "../app/format";
|
||||
import { Icon } from "../components/icons";
|
||||
import { EmptyState, WorkspaceHeader } from "../components/states";
|
||||
import { EvidenceInspector } from "../components/timeline";
|
||||
|
||||
export function HuntingView({ data, state, providers, results, selected, activeIncident, timestampDisplay, inspectorTab, incidentEventIds, onChange, onReset, onSelectEvent, onMove, onTab, onOpenTimeline, onOpenFile, onDemo }: { data: Analysis | null; state: HuntingState; providers: string[]; results: EventItem[]; selected: EventItem | null; activeIncident: Incident | null; timestampDisplay: TimestampDisplay; inspectorTab: InspectorTab; incidentEventIds: Set<string>; onChange: (patch: Partial<HuntingState>) => void; onReset: () => void; onSelectEvent: (event: EventItem) => void; onMove: (delta: number) => void; onTab: (tab: InspectorTab) => void; onOpenTimeline: () => void; onOpenFile: () => void; onDemo: () => void }) {
|
||||
if (!data) return <><WorkspaceHeader eyebrow="Workspace 03 · Exploration" title="Hunting" description="Search the loaded normalized event set with compact investigative facets." /><EmptyState onOpen={onOpenFile} onDemo={onDemo} /></>;
|
||||
const active = Boolean(state.search || state.eventId || state.provider || state.category || state.level || state.computer || state.windowMinutes || state.incidentOnly);
|
||||
return <><WorkspaceHeader eyebrow="Workspace 03 · Exploration" title="Hunting" description="Combine event, provider, host and severity facets to find evidence without changing the source analysis." /><section className="hunting-query panel"><div className="hunting-query-main"><label className="search-field large"><Icon name="search" /><span className="sr-only">Search hunting evidence</span><input aria-label="Search hunting evidence" value={state.search} onChange={(event) => onChange({ search: event.target.value })} placeholder="Search summaries, fields, paths, IDs or providers…" /></label><span className="result-count" role="status"><strong>{results.length.toLocaleString()}</strong> of {data.events.length.toLocaleString()} matching events</span></div><div className="hunting-facets"><label><span>Event ID</span><input aria-label="Hunting event ID" inputMode="numeric" value={state.eventId} onChange={(event) => onChange({ eventId: event.target.value.replace(/[^0-9]/g, "") })} placeholder="4625" /></label><label><span>Provider</span><select aria-label="Hunting provider" value={state.provider} onChange={(event) => onChange({ provider: event.target.value })}><option value="">All providers</option>{providers.map((provider) => <option key={provider}>{provider}</option>)}</select></label><label><span>Category</span><select aria-label="Hunting category" value={state.category} onChange={(event) => onChange({ category: event.target.value })}><option value="">All categories</option>{CATEGORIES.map((category) => <option key={category}>{category}</option>)}</select></label><label><span>Level</span><select aria-label="Hunting level" value={state.level} onChange={(event) => onChange({ level: event.target.value })}><option value="">All levels</option>{LEVELS.map((level) => <option key={level}>{level}</option>)}</select></label><label><span>Computer / host</span><input aria-label="Hunting computer" value={state.computer} onChange={(event) => onChange({ computer: event.target.value })} placeholder="DEMO-PC" /></label><label><span>Time window</span><select aria-label="Hunting time window" value={state.windowMinutes} onChange={(event) => onChange({ windowMinutes: Number(event.target.value) })} disabled={!selected?.timestamp}><option value={0}>All time</option>{WINDOWS.filter(Boolean).map((value) => <option value={value} key={value}>± {value} min</option>)}</select></label><label className="toggle-control facet-toggle"><input type="checkbox" checked={state.incidentOnly} onChange={(event) => onChange({ incidentOnly: event.target.checked })} /><span className="toggle-track" /><span>Known incident evidence only</span></label><button type="button" className="reset-button" onClick={onReset} disabled={!active}><Icon name="reset" />Reset hunting</button></div></section><section className="hunting-layout"><article className="hunting-results panel"><header className="panel-header"><div><p className="eyebrow">Normalized event set</p><h2>Results</h2></div><span className="panel-count">{results.length.toLocaleString()} found</span></header>{results.length ? <div className="hunting-result-list" role="list">{results.map((event) => <div role="listitem" key={event.id}><button type="button" className={`hunting-result ${selected?.id === event.id ? "active" : ""}`} onClick={() => onSelectEvent(event)}><span className="result-time">{formatDate(event.timestamp, timestampDisplay)}<br />{formatClock(event.timestamp, timestampDisplay)}</span><strong>{event.event_id ?? "—"}</strong><span className="result-main"><b>{event.provider}</b><span>{event.summary}</span></span><span className="result-meta">{event.computer || "—"}<br /><small className={`level-text level-${event.level}`}>{levelLabel(event.level)}</small></span><span className={`membership-dot ${incidentEventIds.has(event.id) ? "known" : ""}`} title={incidentEventIds.has(event.id) ? "Known incident evidence" : "Unlinked event"} aria-label={incidentEventIds.has(event.id) ? "Known incident evidence" : "Unlinked event"} /></button></div>)}</div> : <div className="quiet-empty"><h3>No matching events</h3><p>Try clearing a facet or search the complete normalized dataset.</p>{active ? <button type="button" className="text-button" onClick={onReset}>Reset hunting filters</button> : null}</div>}</article><EvidenceInspector data={data} event={selected} visibleEvents={results} activeIncident={activeIncident} timestampDisplay={timestampDisplay} tab={inspectorTab} onTab={onTab} onSelectEvent={onSelectEvent} onMove={onMove} onOpenTimeline={onOpenTimeline} /></section><p className="hunting-hint"><Icon name="info" /> Results are local normalized evidence. A known-incident marker means the event is linked by an existing deterministic rule; it does not establish causality.</p></>;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { prepareExport } from "../export";
|
||||
import type { Analysis } from "../types";
|
||||
import { sourceLabel, statusLabel } from "../app/format";
|
||||
import type { TimestampDisplay } from "../app/model";
|
||||
import { Icon } from "../components/icons";
|
||||
import { EmptyState, WorkspaceHeader } from "../components/states";
|
||||
|
||||
export function ReportsView({ data, redactExports, redactionPreview, onRedactionChange, onExportJson, onExportMarkdown, timestampDisplay, onOpenFile, onDemo }: { data: Analysis | null; redactExports: boolean; redactionPreview: ReturnType<typeof prepareExport>["redaction"]; onRedactionChange: (value: boolean) => void; onExportJson: () => void; onExportMarkdown: () => void; timestampDisplay: TimestampDisplay; onOpenFile: () => void; onDemo: () => void }) {
|
||||
if (!data) return <><WorkspaceHeader eyebrow="Workspace 04 · Output" title="Reports" description="Review completeness and privacy choices before exporting an investigation." /><EmptyState onOpen={onOpenFile} onDemo={onDemo} /></>;
|
||||
const parse = data.parse;
|
||||
const readiness = parse?.status === "partial" ? "Review completeness before sharing" : redactExports ? "Ready for review/export" : "Privacy review recommended";
|
||||
return <><WorkspaceHeader eyebrow="Workspace 04 · Output" title="Reports" description="Review the exact analysis metadata, parser issues and redaction behavior before sharing." /><section className={`export-readiness ${parse?.status === "partial" ? "partial" : redactExports ? "ready" : "caution"}`}><Icon name={parse?.status === "partial" || !redactExports ? "warning" : "export"} /><div><strong>{readiness}</strong><span>{parse?.status === "partial" ? "The parser retained a partial result; incident reconstruction may be incomplete." : redactExports ? "Redaction substitutions are enabled. Review the generated output before sharing." : "Raw normalized evidence will be exported unless redaction is enabled."}</span></div></section><div className="report-grid"><section className="report-card panel"><div className="section-heading"><div><p className="eyebrow">Investigation summary</p><h2>Analysis metadata</h2></div></div><dl className="report-facts"><div><dt>Source</dt><dd title={data.source}>{sourceLabel(data.source)}</dd></div><div><dt>Version</dt><dd>EventLens v{import.meta.env.VITE_EVENTLENS_VERSION ?? "development"}</dd></div><div><dt>Events</dt><dd>{data.events.length.toLocaleString()}</dd></div><div><dt>Incidents</dt><dd>{data.incidents.length}</dd></div><div><dt>Timestamp display</dt><dd>{timestampDisplay === "utc" ? "UTC" : "Local browser time"}</dd></div></dl></section><section className="report-card panel"><div className="section-heading"><div><p className="eyebrow">Completeness</p><h2>Parser status</h2></div><span className={`status-badge status-${parse?.status ?? "demo"}`}>{statusLabel(data)}</span></div><div className="completeness-summary"><strong>{parse ? `${parse.parsed_events.toLocaleString()} retained` : "Demo fixture loaded"}</strong><span>{parse ? `${parse.records_seen.toLocaleString()} records seen · ${parse.skipped_records.toLocaleString()} skipped · ${parse.issue_count.toLocaleString()} issue(s)` : "The bundled demo is deterministic and bypasses raw EVTX parsing."}</span></div>{parse?.truncation_reason ? <p className="warning-copy">Truncation reason: <code>{parse.truncation_reason}</code></p> : null}{parse?.issues.length ? <ul className="issue-list">{parse.issues.map((issue, index) => <li key={`${issue.code}-${index}`}><code>{issue.code}</code><span>{issue.message}</span></li>)}</ul> : <p className="muted-copy">No parser issues were reported.</p>}</section><section className="report-card panel privacy-card"><div className="section-heading"><div><p className="eyebrow">Privacy before sharing</p><h2>Redaction preview</h2></div><label className="toggle-control"><input type="checkbox" checked={redactExports} onChange={(event) => onRedactionChange(event.target.checked)} /><span className="toggle-track" /><span>Redact export</span></label></div><p>Redaction is best effort. It propagates known structured values into summaries and applies conservative patterns; review the exported file before sharing.</p>{redactExports ? <div className="redaction-stats">{([["replacements", redactionPreview?.total ?? 0], ["hosts", redactionPreview?.hosts ?? 0], ["identities", redactionPreview?.identities ?? 0], ["emails", redactionPreview?.emails ?? 0], ["IPs", redactionPreview?.ips ?? 0], ["paths", redactionPreview?.paths ?? 0], ["sources", redactionPreview?.sources ?? 0]] as const).map(([label, value]) => <span key={label}><strong>{value}</strong>{label}</span>)}</div> : <div className="raw-export-warning"><Icon name="warning" /><span>Raw normalized evidence will be exported. Review carefully before sharing.</span></div>}</section><section className="report-card panel export-card"><div className="section-heading"><div><p className="eyebrow">Portable output</p><h2>Export report</h2></div></div><p>JSON preserves the analysis contract. Markdown is formatted for human review. Both include completeness metadata, parser issues and evidence IDs.</p><div className="export-actions"><button type="button" className="primary-button" onClick={onExportMarkdown}><Icon name="export" />Export Markdown</button><button type="button" className="secondary-button" onClick={onExportJson}><Icon name="export" />Export JSON</button></div></section></div></>;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { WORKSPACES, type UiSettings } from "../app/model";
|
||||
import { Icon } from "../components/icons";
|
||||
import { WorkspaceHeader } from "../components/states";
|
||||
|
||||
type SettingsViewProps = {
|
||||
settings: UiSettings;
|
||||
saveNotice: string;
|
||||
storageFailed: boolean;
|
||||
storageConfirmed: boolean;
|
||||
onChange: (patch: Partial<UiSettings>) => void;
|
||||
onReset: () => void;
|
||||
};
|
||||
|
||||
type OptionCardProps = {
|
||||
checked: boolean;
|
||||
description: string;
|
||||
name: string;
|
||||
onChange: () => void;
|
||||
title: string;
|
||||
};
|
||||
|
||||
function OptionCard({ checked, description, name, onChange, title }: OptionCardProps) {
|
||||
const inputId = `${name}-${title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
|
||||
return (
|
||||
<label className={`preference-option ${checked ? "selected" : ""}`} htmlFor={inputId}>
|
||||
<input className="sr-only" id={inputId} type="radio" name={name} checked={checked} onChange={onChange} />
|
||||
<span className="preference-option-indicator" aria-hidden="true" />
|
||||
<span>
|
||||
<strong>{title}</strong>
|
||||
<small>{description}</small>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsView({ settings, saveNotice, storageFailed, storageConfirmed, onChange, onReset }: SettingsViewProps) {
|
||||
const headerAction = (
|
||||
<div className="settings-header-status">
|
||||
{storageConfirmed && !storageFailed ? <span className="settings-saved-automatically"><Icon name="check" />Saved automatically in this browser</span> : null}
|
||||
<span className="settings-save-feedback" role="status" aria-live="polite">{saveNotice}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<WorkspaceHeader
|
||||
eyebrow="Workspace 05 · Local preferences"
|
||||
title="Settings"
|
||||
description="Browser-local display defaults for this device. Investigation evidence is never stored here."
|
||||
action={headerAction}
|
||||
/>
|
||||
<div className="settings-layout">
|
||||
<div className="settings-preferences">
|
||||
<section className="settings-section panel" aria-labelledby="display-time-heading">
|
||||
<header>
|
||||
<Icon name="clock" />
|
||||
<div>
|
||||
<h2 id="display-time-heading">Display & time</h2>
|
||||
<p>Choose a stable forensic reference or a local reading context.</p>
|
||||
</div>
|
||||
</header>
|
||||
<fieldset>
|
||||
<legend>Timestamp display</legend>
|
||||
<div className="preference-option-grid">
|
||||
<OptionCard name="timestamp-display" title="UTC" description="Stable forensic reference time" checked={settings.timestampDisplay === "utc"} onChange={() => onChange({ timestampDisplay: "utc" })} />
|
||||
<OptionCard name="timestamp-display" title="Local browser time" description="Useful for local investigation context" checked={settings.timestampDisplay === "local"} onChange={() => onChange({ timestampDisplay: "local" })} />
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Interface density</legend>
|
||||
<div className="preference-option-grid">
|
||||
<OptionCard name="density" title="Comfortable" description="More breathing room for review" checked={settings.density === "comfortable"} onChange={() => onChange({ density: "comfortable" })} />
|
||||
<OptionCard name="density" title="Compact" description="More evidence visible at once" checked={settings.density === "compact"} onChange={() => onChange({ density: "compact" })} />
|
||||
</div>
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
<section className="settings-section panel" aria-labelledby="investigation-defaults-heading">
|
||||
<header>
|
||||
<Icon name="analysis" />
|
||||
<div>
|
||||
<h2 id="investigation-defaults-heading">Investigation defaults</h2>
|
||||
<p>New investigations use these defaults; matching display/export controls update immediately.</p>
|
||||
</div>
|
||||
</header>
|
||||
<div className="preference-rows">
|
||||
<label className="preference-row">
|
||||
<span>
|
||||
<strong>Informational noise</strong>
|
||||
<small>Hide routine informational events by default and update the current Timeline filter.</small>
|
||||
</span>
|
||||
<select aria-label="Default noise mode" value={settings.defaultNoise} onChange={(event) => onChange({ defaultNoise: event.target.value as UiSettings["defaultNoise"] })}>
|
||||
<option value="attention">Hide by default</option>
|
||||
<option value="all">Show by default</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="preference-row">
|
||||
<span>
|
||||
<strong>Default workspace</strong>
|
||||
<small>Workspace opened when EventLens starts without a deep link.</small>
|
||||
</span>
|
||||
<select aria-label="Default workspace" value={settings.defaultWorkspace} onChange={(event) => onChange({ defaultWorkspace: event.target.value as UiSettings["defaultWorkspace"] })}>
|
||||
{WORKSPACES.map((workspace) => <option key={workspace} value={workspace}>{workspace[0].toUpperCase() + workspace.slice(1)}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="preference-row preference-switch-row">
|
||||
<span>
|
||||
<strong>Export redaction</strong>
|
||||
<small>Recommended · Redaction remains best effort and should be reviewed.</small>
|
||||
</span>
|
||||
<span className="preference-switch">
|
||||
<input className="sr-only" type="checkbox" role="switch" aria-label="Export redaction by default" checked={settings.defaultRedaction} onChange={(event) => onChange({ defaultRedaction: event.target.checked })} />
|
||||
<span className="preference-switch-track" aria-hidden="true"><span /></span>
|
||||
<b aria-hidden="true">{settings.defaultRedaction ? "On" : "Off"}</b>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside className="settings-privacy-panel panel" aria-labelledby="local-privacy-heading">
|
||||
<header>
|
||||
<Icon name="privacy" />
|
||||
<div>
|
||||
<p className="eyebrow">{storageFailed ? "Session only" : "Browser-local only"}</p>
|
||||
<h2 id="local-privacy-heading">Local data & privacy</h2>
|
||||
</div>
|
||||
</header>
|
||||
<p className="privacy-lead">{storageFailed ? "Preferences are available for this session only because browser storage is unavailable." : "Only your interface preferences remain in this browser."}</p>
|
||||
<div className="privacy-facts">
|
||||
<section>
|
||||
<strong>{storageFailed ? "Not saved to browser storage" : "Stored locally"}</strong>
|
||||
<span>{storageFailed ? "UI preferences for this session only" : "UI preferences only"}</span>
|
||||
</section>
|
||||
<section>
|
||||
<strong>Never stored there</strong>
|
||||
<ul>
|
||||
<li>Event text</li>
|
||||
<li>Analysis results</li>
|
||||
<li>Uploaded EVTX files</li>
|
||||
<li>Incident evidence</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
<details className="settings-technical-details">
|
||||
<summary>Technical details</summary>
|
||||
<p>{storageFailed ? "When browser storage is available, preferences use this validated record:" : "The browser uses one validated preference record:"}</p>
|
||||
<code>eventlens.preferences.v1</code>
|
||||
</details>
|
||||
<button type="button" className="secondary-button settings-reset" onClick={onReset}>
|
||||
<Icon name="reset" />Reset all preferences
|
||||
</button>
|
||||
</aside>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Analysis, EventItem, Incident } from "../types";
|
||||
import type { FilterState, InspectorTab, TimestampDisplay } from "../app/model";
|
||||
import { EmptyState, WorkspaceHeader } from "../components/states";
|
||||
import { EvidenceInspector, FilterToolbar, IncidentContext, TimelinePanel } from "../components/timeline";
|
||||
|
||||
export function TimelineView({ data, filters, providers, visibleEvents, selected, activeIncident, activeIncidentId, focusIncident, timestampDisplay, rowHeight, inspectorTab, onFilterChange, onResetFilters, onFocusChange, onSelectIncident, onSelectEvent, onMove, onTab, onOpenFile, onDemo }: { data: Analysis | null; filters: FilterState; providers: string[]; visibleEvents: EventItem[]; selected: EventItem | null; activeIncident: Incident | null; activeIncidentId: string; focusIncident: boolean; timestampDisplay: TimestampDisplay; rowHeight: number; inspectorTab: InspectorTab; onFilterChange: (patch: Partial<FilterState>) => void; onResetFilters: () => void; onFocusChange: (value: boolean) => void; onSelectIncident: (id: string) => void; onSelectEvent: (event: EventItem) => void; onMove: (delta: number) => void; onTab: (tab: InspectorTab) => void; onOpenFile: () => void; onDemo: () => void }) {
|
||||
if (!data) return <><WorkspaceHeader eyebrow="Workspace 02 · Investigation" title="Timeline" description="Search and inspect normalized evidence in chronological context." /><EmptyState onOpen={onOpenFile} onDemo={onDemo} /></>;
|
||||
return <><WorkspaceHeader eyebrow="Workspace 02 · Investigation" title="Timeline" description="The primary forensic workspace for event sequence, incident evidence and normalized details." /><IncidentContext incidents={data.incidents} activeIncidentId={activeIncidentId} focusIncident={focusIncident} onSelect={onSelectIncident} onFocus={onFocusChange} /><FilterToolbar filters={filters} providers={providers} visibleCount={visibleEvents.length} totalCount={data.events.length} anchorTimestamp={selected?.timestamp ?? activeIncident?.start ?? null} activeIncident={activeIncident} focusIncident={focusIncident} onFocusChange={onFocusChange} onChange={onFilterChange} onReset={onResetFilters} /><section className="timeline-layout"><TimelinePanel events={visibleEvents} selectedId={selected?.id ?? null} rowHeight={rowHeight} timestampDisplay={timestampDisplay} onSelect={onSelectEvent} /><EvidenceInspector data={data} event={selected} visibleEvents={visibleEvents} activeIncident={activeIncident} timestampDisplay={timestampDisplay} tab={inspectorTab} onTab={onTab} onSelectEvent={onSelectEvent} onMove={onMove} /></section></>;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "vite";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const version = readFileSync(resolve(fileURLToPath(new URL(".", import.meta.url)), "../VERSION"), "utf-8").trim();
|
||||
|
||||
export default defineConfig({
|
||||
define: {
|
||||
"import.meta.env.VITE_EVENTLENS_VERSION": JSON.stringify(version),
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
exclude: ["e2e/**", "node_modules/**", "dist/**"],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
[
|
||||
{"id":"e1","timestamp":"2026-09-09T08:31:02Z","provider":"Microsoft-Windows-Kernel-General","event_id":12,"level":"Information","channel":"System","computer":"DEMO-PC","category":"system","summary":"Operating system started","fields":{}},
|
||||
{"id":"e2","timestamp":"2026-09-09T08:31:18Z","provider":"Microsoft-Windows-NetworkProfile","event_id":10000,"level":"Information","channel":"Microsoft-Windows-NetworkProfile/Operational","computer":"DEMO-PC","category":"network","summary":"Network connection established","fields":{"profile":"Office LAN"}},
|
||||
{"id":"e3","timestamp":"2026-09-09T08:32:07Z","provider":"Service Control Manager","event_id":7031,"level":"Error","channel":"System","computer":"DEMO-PC","category":"service","summary":"Example Sync Service terminated unexpectedly","fields":{"service":"Example Sync Service"}},
|
||||
{"id":"e4","timestamp":"2026-09-09T08:32:11Z","provider":"Microsoft-Windows-DNS-Client","event_id":1014,"level":"Warning","channel":"System","computer":"DEMO-PC","category":"network","summary":"DNS name resolution timed out","fields":{"query":"api.example.test"}},
|
||||
{"id":"e5","timestamp":"2026-09-09T08:34:54Z","provider":"Application Error","event_id":1000,"level":"Error","channel":"Application","computer":"DEMO-PC","category":"crash","summary":"ExampleApp.exe crashed","fields":{"process":"ExampleApp.exe","module":"example.dll","exception":"0xc0000005"}},
|
||||
{"id":"e6","timestamp":"2026-09-09T08:35:02Z","provider":"Windows Error Reporting","event_id":1001,"level":"Information","channel":"Application","computer":"DEMO-PC","category":"crash","summary":"Crash report created for ExampleApp.exe","fields":{"process":"ExampleApp.exe"}}
|
||||
]
|
||||