diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 1a58cc244..c71f4076d 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -6,11 +6,6 @@ on: - master workflow_dispatch: -permissions: - contents: read - pages: write - id-token: write - concurrency: group: "pages" cancel-in-progress: false @@ -18,8 +13,13 @@ concurrency: jobs: build: runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read steps: - uses: actions/checkout@v6 + with: + persist-credentials: false - name: Set up Python uses: actions/setup-python@v6 @@ -55,7 +55,11 @@ jobs: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest + timeout-minutes: 10 needs: build + permissions: + pages: write + id-token: write steps: - name: Deploy to GitHub Pages id: deployment diff --git a/.github/workflows/docs-preview.yml b/.github/workflows/docs-preview.yml new file mode 100644 index 000000000..8611a14d7 --- /dev/null +++ b/.github/workflows/docs-preview.yml @@ -0,0 +1,71 @@ +name: Docs Preview + +# Builds the documentation on a pull request and uploads the fully rendered +# site as a downloadable artifact so reviewers can view the rendered pages +# before merge. +# +# Security posture: build only. There is no deploy step, no secrets are used, +# and the token is read-only, so this cannot publish anywhere or leak +# credentials. It runs only for pull requests from branches in this repository +# (collaborators), not from forks. + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build-preview: + name: Build rendered docs + runs-on: ubuntu-latest + timeout-minutes: 15 + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + pip install --upgrade pip + pip install -r requirements.txt + + - name: Build documentation + run: mkdocs build --strict + + # Fails the PR only on NEW ordered-list numbering breaks (an item the + # author numbered >1 that renders as a restarted "1." because its content + # is mis-indented). Pre-existing cases are recorded in the baseline and do + # not fail; see scripts/check-list-numbering.py for the full rationale. + - name: Check ordered-list numbering + run: python scripts/check-list-numbering.py docs site --baseline scripts/list-numbering-baseline.json + + - name: Upload rendered site + uses: actions/upload-artifact@v4 + with: + name: docs-preview + path: site + retention-days: 1 + + - name: How to view the preview + run: | + { + echo "### Rendered docs preview" + echo "" + echo "Download the **docs-preview** artifact from this run (the Artifacts section above), unzip it, then serve it locally:" + echo "" + echo '```bash' + echo "cd docs-preview # the unzipped folder" + echo "python -m http.server 8000" + echo '```' + echo "" + echo "Open in your browser. Serving over HTTP (rather than opening index.html directly) makes internal links and search work correctly." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml deleted file mode 100644 index 073ce628b..000000000 --- a/.github/workflows/docs.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Deploy Documentation - -on: - push: - branches: - - master - workflow_dispatch: - -permissions: - contents: write - -jobs: - deploy: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: '3.11' - - - name: Cache Python dependencies - uses: actions/cache@v5 - with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} - restore-keys: | - ${{ runner.os }}-pip- - - - name: Install dependencies - run: | - pip install --upgrade pip - pip install -r requirements.txt - - - name: Configure Git - run: | - git config user.name github-actions - git config user.email github-actions@github.com - - - name: Build and deploy documentation - run: mkdocs gh-deploy --force --clean --verbose diff --git a/.github/workflows/lexer-tests.yml b/.github/workflows/lexer-tests.yml new file mode 100644 index 000000000..c06b165cc --- /dev/null +++ b/.github/workflows/lexer-tests.yml @@ -0,0 +1,41 @@ +name: Lexer Tests + +on: + push: + branches: + - master + pull_request: + workflow_dispatch: + +# Minimum permissions: this workflow only reads the repository. The job token +# has no write scope and no secrets are exposed to it. +permissions: + contents: read + +jobs: + test: + name: LCQL lexer unit tests + runs-on: ubuntu-latest + timeout-minutes: 10 + # Run for repository events (push to master, manual dispatch) and for pull + # requests only when they come from a branch in this repository, never from + # a fork. Untrusted contributors therefore cannot trigger this job. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + pip install --upgrade pip + pip install -r requirements.txt pytest + + - name: Run LCQL lexer tests + run: pytest tests/ -v diff --git a/.github/workflows/link-checker.yml b/.github/workflows/link-checker.yml index 4169602ae..f126ad012 100644 --- a/.github/workflows/link-checker.yml +++ b/.github/workflows/link-checker.yml @@ -16,17 +16,25 @@ on: # Run weekly on Sundays at midnight UTC - cron: '0 0 * * 0' +# Least privilege at the workflow scope; jobs opt in to more where needed. permissions: contents: read - pull-requests: write jobs: check-links: runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + # Only run for pull requests from branches in this repository (collaborators), + # not from forks; external contributions are not accepted for this repository. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository steps: - name: Checkout repository uses: actions/checkout@v6 + with: + persist-credentials: false - name: Set up Python uses: actions/setup-python@v6 @@ -57,7 +65,9 @@ jobs: restore-keys: | lychee-cache- - # PRs: internal links only (fast, catches broken cross-references) + # PRs: internal links only (fast, catches broken cross-references). + # Blocking (fail: true): a broken internal cross-reference is a real + # regression introduced by the PR, so it should fail the check. - name: Check internal links (PR) if: github.event_name == 'pull_request' uses: lycheeverse/lychee-action@8646ba30535128ac92d33dfc9133794bfdd9b411 # v2.8.0 @@ -69,10 +79,12 @@ jobs: --exclude '^https?://' --exclude '^mailto:' ./site - fail: false + fail: true output: ./linkcheck-results.txt - # Push/schedule/manual: full check including external links + # Push/schedule/manual: full check including external links. + # Advisory (fail: false): external sites go down or rate-limit for reasons + # outside our control, so a transient external failure must not block. - name: Check all links if: github.event_name != 'pull_request' uses: lycheeverse/lychee-action@8646ba30535128ac92d33dfc9133794bfdd9b411 # v2.8.0 @@ -94,10 +106,32 @@ jobs: with: name: linkcheck-results path: linkcheck-results.txt - retention-days: 7 + retention-days: 1 + + # The comment job is split out so that pull-requests: write is granted only to + # the step that needs it, not to the job that builds the docs and runs lychee. + comment-links: + needs: check-links + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + pull-requests: write + # Post the results comment even when the link check above failed, but only + # for pull requests from branches in this repository (not forks). + if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + + steps: + # If check-links failed before lychee ran (e.g. mkdocs build broke), the + # results artifact is never created. Don't fail this job on a missing + # artifact; the script step below no-ops when the file is absent. + - name: Download link check results + uses: actions/download-artifact@v8 + continue-on-error: true + with: + name: linkcheck-results - name: Comment on PR with broken links - if: github.event_name == 'pull_request' uses: actions/github-script@v8 with: script: | @@ -138,12 +172,17 @@ jobs: check-markdown: runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read steps: - name: Checkout repository uses: actions/checkout@v6 + with: + persist-credentials: false - name: Check markdown formatting - uses: DavidAnson/markdownlint-cli2-action@v22 + uses: DavidAnson/markdownlint-cli2-action@07035fd053f7be764496c0f8d8f9f41f98305101 # v22 with: globs: 'docs/**/*.md' diff --git a/.github/workflows/publish-release-notes.yml b/.github/workflows/publish-release-notes.yml index dea9eea7a..3e7b9059e 100644 --- a/.github/workflows/publish-release-notes.yml +++ b/.github/workflows/publish-release-notes.yml @@ -10,6 +10,7 @@ permissions: jobs: publish: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/snippet-tests.yml b/.github/workflows/snippet-tests.yml new file mode 100644 index 000000000..3d37bfe11 --- /dev/null +++ b/.github/workflows/snippet-tests.yml @@ -0,0 +1,87 @@ +name: Snippet Compilation + +# Compiles the documentation code snippets (embedded via pymdownx.snippets) so +# they cannot drift from the published SDKs. +# +# Security posture: +# * The snippets are only COMPILED, never EXECUTED. Go snippets are +# type-checked with `go build` / `go vet` (which never run the programs), +# and Python snippets are byte-compiled with `py_compile` (which never runs +# them). A snippet edited to do something malicious cannot execute here. +# * Jobs run with read-only permissions and no secrets are exposed. +# * Pull requests from forks do not run; external contributions are not +# accepted for this repository. + +on: + push: + branches: + - master + pull_request: + workflow_dispatch: + +# Minimum permissions: read the repository only. +permissions: + contents: read + +jobs: + go-snippets: + name: Go snippets (compile only) + runs-on: ubuntu-latest + timeout-minutes: 15 + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + env: + # Build against the committed go.mod / go.sum; fail instead of mutating them. + GOFLAGS: -mod=readonly + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: stable + + - name: Compile (do not run) the Go snippets + working-directory: snippets/golang + run: | + go build ./... + go vet ./... + + python-snippets: + name: Python snippets (compile only) + runs-on: ubuntu-latest + timeout-minutes: 10 + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - name: Install the LimaCharlie SDK + # Pin the SDK version the snippets target so this check is reproducible + # (matching the Go side's pinned module) and a later SDK release that + # reorganizes modules fails the PR that bumps it, not an unrelated PR. + run: | + pip install --upgrade pip + pip install 'limacharlie==5.5.1' + + - name: Byte-compile (do not run) the Python snippets + run: python -m py_compile snippets/python/*.py + + - name: Verify SDK import paths used by the snippets + run: | + python - <<'PY' + from limacharlie.client import Client + from limacharlie.sdk.organization import Organization + from limacharlie.sdk.search import Search + from limacharlie.sdk.hive import Hive, HiveRecord + print("SDK import paths used by the documentation snippets all resolve.") + PY diff --git a/CLAUDE.md b/CLAUDE.md index 8a29635de..cc20295d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -163,3 +163,15 @@ plat == linux and hostname contains "web" # Linux with "web" in hostname plat == windows and not isolated # Non-isolated Windows ext_plat == windows # Carbon Black/Crowdstrike reporting Windows endpoints ``` + +## Documentation Snippet Accuracy + +When adding or editing documentation that includes code snippets or commands, verify them against authoritative, PUBLIC sources before publishing. Do not rely on memory. + +- **SDK snippets (Python, Go, and any other SDK):** Cross-check every module path, class, function/method name, and argument against the latest STABLE release of the corresponding public SDK - for example the `python-limacharlie` and `go-limacharlie` repositories on GitHub, or the `limacharlie` package on PyPI. Confirm signatures match the released version, not an older or in-development one. +- **CLI commands and flags:** Cross-check subcommands, flags, and arguments against the latest stable `limacharlie` CLI (from PyPI or the public `python-limacharlie` repository). Use `limacharlie --ai-help` or `--help` to confirm. +- **LCQL queries and syntax:** Confirm operators, aggregation functions, field-path syntax, and query structure against the authoritative LCQL grammar, and validate example queries with `limacharlie search validate` before publishing. + +Runnable Python and Go SDK examples live in `snippets/` (`snippets/python/*.py`, `snippets/golang/*/main.go`) and are embedded into the docs with `pymdownx.snippets` (`--8<--`). They are compiled in CI (`.github/workflows/snippet-tests.yml`): Go snippets are built with `go build`, and Python snippets are byte-compiled with their SDK import paths verified. Add or edit runnable SDK examples there rather than inline, so CI keeps them from drifting out of sync with the SDKs. + +**Reminder:** This repository is PUBLIC. Only reference and link public sources; never include internal repository names, internal URLs, org IDs, or other non-public details in documentation or examples. diff --git a/docs/4-data-queries/index.md b/docs/4-data-queries/index.md index 6bb496241..566cc4cd7 100644 --- a/docs/4-data-queries/index.md +++ b/docs/4-data-queries/index.md @@ -7,6 +7,7 @@ Query and analyze your security telemetry using LimaCharlie Query Language (LCQL - [LCQL Examples](lcql-examples.md) - Example queries for common use cases - [Query Console UI](query-console-ui.md) - Using the web-based query interface - [Query with CLI](query-cli.md) - Running queries from the command line +- [Query Limits & Performance](query-limits-and-performance.md) - Concurrency and timeout limits, and how to write efficient queries --- @@ -15,6 +16,40 @@ Query and analyze your security telemetry using LimaCharlie Query Language (LCQL !!! info "Prerequisites" All API examples require an API key with the `insight` permission. See [API Keys](../7-administration/access/api-keys.md) for setup. +### Search API Endpoint + +There is no single search hostname. Each organization's search endpoint lives in the datacenter for the region where the organization was created, so you discover it from the API first and then send queries to that host. The Python SDK, Go SDK, and CLI do this automatically. + +=== "REST API" + + ```bash + # Returns a bare hostname such as 9157798c50af372c.replay-search.limacharlie.io + SEARCH_HOST=$(curl -s "https://api.limacharlie.io/v1/orgs/YOUR_OID/url" \ + -H "Authorization: Bearer $LC_JWT" | jq -r '.url.search') + ``` + +=== "Python" + + The Python SDK resolves the search endpoint automatically from your OID; no manual step is required. + +=== "CLI" + + The CLI resolves the search endpoint automatically from your OID; no manual step is required. + +The bootstrap API host `https://api.limacharlie.io` is the same for every region and routes to the correct datacenter based on your OID. The REST examples below reuse the `$SEARCH_HOST` variable for the discovered hostname. For reference, the current production search endpoints per region are: + +| Region | Search endpoint | +|--------|-----------------| +| USA | `https://9157798c50af372c.replay-search.limacharlie.io` | +| Europe | `https://b76093c3662d5b4f.replay-search.limacharlie.io` | +| Canada | `https://aae67d7e76570ec1.replay-search.limacharlie.io` | +| UK | `https://70182cf634c346bd.replay-search.limacharlie.io` | +| Australia | `https://abc32764762fce67.replay-search.limacharlie.io` | +| India | `https://4d897015b0815621.replay-search.limacharlie.io` | + +!!! tip "Use the bootstrap API, not a direct endpoint" + Discovering your search endpoint through the bootstrap API (`https://api.limacharlie.io/v1/orgs/{oid}/url`, the `url.search` field) is the recommended approach. The direct per-region hostnames above can change over time, whereas the bootstrap API always returns the current endpoint for your organization. Treat the table as a convenience reference only, and do not hardcode a direct endpoint. + ### Run an LCQL Query === "REST API" @@ -24,13 +59,17 @@ Query and analyze your security telemetry using LimaCharlie Query Language (LCQL START=$(date -d '1 hour ago' +%s) END=$(date +%s) + # Discover your org's search endpoint (see "Search API Endpoint" above) + SEARCH_HOST=$(curl -s "https://api.limacharlie.io/v1/orgs/YOUR_OID/url" \ + -H "Authorization: Bearer $LC_JWT" | jq -r '.url.search') + curl -s -X POST \ - "https://search.limacharlie.io/v1/search" \ + "https://$SEARCH_HOST/v1/search" \ -H "Authorization: Bearer $LC_JWT" \ -H "Content-Type: application/json" \ -d '{ "oid": "YOUR_OID", - "query": "event.FILE_PATH ends with .exe", + "query": "event/FILE_PATH ends with .exe", "startTime": "'"$START"'", "endTime": "'"$END"'", "stream": "event" @@ -40,53 +79,13 @@ Query and analyze your security telemetry using LimaCharlie Query Language (LCQL === "Python" ```python - import time - from limacharlie.client import Client - from limacharlie.sdk.organization import Organization - from limacharlie.sdk.search import Search - - client = Client(oid="YOUR_OID", api_key="YOUR_API_KEY") - org = Organization(client) - - end = int(time.time()) - start = end - 3600 # 1 hour ago - - for result in Search(org).execute( - query="event.FILE_PATH ends with .exe", - start_time=start, - end_time=end, - stream="event", - limit=100, - ): - print(result) + --8<-- "snippets/python/run.py" ``` === "Go" ```go - package main - - import ( - "fmt" - limacharlie "github.com/refractionPOINT/go-limacharlie/limacharlie" - ) - - func main() { - client, _ := limacharlie.NewClient(limacharlie.ClientOptions{ - OID: "YOUR_OID", - APIKey: "YOUR_API_KEY", - }) - org := limacharlie.NewOrganization(client) - - resp, _ := org.Query(limacharlie.QueryRequest{ - Query: "-1h | * | * | event.FILE_PATH ends with '.exe'", - Stream: "event", - LimitEvent: 1000, - }) - for _, r := range resp.Results { - fmt.Println(r) - } - } + --8<-- "snippets/golang/run/main.go" ``` === "CLI" @@ -96,7 +95,7 @@ Query and analyze your security telemetry using LimaCharlie Query Language (LCQL END=$(date +%s) limacharlie search run \ - --query "event.FILE_PATH ends with .exe" \ + --query "event/FILE_PATH ends with .exe" \ --start "$START" \ --end "$END" \ --stream event \ @@ -109,12 +108,12 @@ Query and analyze your security telemetry using LimaCharlie Query Language (LCQL ```bash curl -s -X POST \ - "https://search.limacharlie.io/v1/search/validate" \ + "https://$SEARCH_HOST/v1/search/validate" \ -H "Authorization: Bearer $LC_JWT" \ -H "Content-Type: application/json" \ -d '{ "oid": "YOUR_OID", - "query": "event.FILE_PATH ends with .exe", + "query": "event/FILE_PATH ends with .exe", "startTime": "'"$(date -d '1 hour ago' +%s)"'", "endTime": "'"$(date +%s)"'" }' @@ -123,25 +122,20 @@ Query and analyze your security telemetry using LimaCharlie Query Language (LCQL === "Python" ```python - from limacharlie.client import Client - from limacharlie.sdk.organization import Organization - from limacharlie.sdk.search import Search - - client = Client(oid="YOUR_OID", api_key="YOUR_API_KEY") - org = Organization(client) - result = Search(org).validate("event.FILE_PATH ends with .exe") - print(result) + --8<-- "snippets/python/validate.py" ``` === "Go" - There is no dedicated Go SDK method for query validation. Use the REST API directly. + ```go + --8<-- "snippets/golang/validate/main.go" + ``` === "CLI" ```bash limacharlie search validate \ - --query "event.FILE_PATH ends with .exe" + --query "event/FILE_PATH ends with .exe" ``` ### Estimate Query Cost @@ -153,12 +147,12 @@ Query and analyze your security telemetry using LimaCharlie Query Language (LCQL END=$(date +%s) curl -s -X POST \ - "https://search.limacharlie.io/v1/search/validate" \ + "https://$SEARCH_HOST/v1/search/validate" \ -H "Authorization: Bearer $LC_JWT" \ -H "Content-Type: application/json" \ -d '{ "oid": "YOUR_OID", - "query": "event.FILE_PATH ends with .exe", + "query": "event/FILE_PATH ends with .exe", "startTime": "'"$START"'", "endTime": "'"$END"'" }' @@ -167,28 +161,14 @@ Query and analyze your security telemetry using LimaCharlie Query Language (LCQL === "Python" ```python - import time - from limacharlie.client import Client - from limacharlie.sdk.organization import Organization - from limacharlie.sdk.search import Search - - client = Client(oid="YOUR_OID", api_key="YOUR_API_KEY") - org = Organization(client) - - end = int(time.time()) - start = end - 3600 - - result = Search(org).estimate( - query="event.FILE_PATH ends with .exe", - start_time=start, - end_time=end, - ) - print(result) + --8<-- "snippets/python/estimate.py" ``` === "Go" - There is no dedicated Go SDK method for query cost estimation. Use the REST API directly. + ```go + --8<-- "snippets/golang/estimate/main.go" + ``` === "CLI" @@ -197,11 +177,14 @@ Query and analyze your security telemetry using LimaCharlie Query Language (LCQL END=$(date +%s) limacharlie search estimate \ - --query "event.FILE_PATH ends with .exe" \ + --query "event/FILE_PATH ends with .exe" \ --start "$START" \ --end "$END" ``` +!!! note + The validate response also includes query-size fields (`batchesInScope`, `eventsInScope`, `bytesInScope`) and a running search returns per-page progress and actual billing. See [Query Progress and Cost Reporting](query-limits-and-performance.md#query-progress-and-cost-reporting) for how to build a progress bar and read the real cost. + ### Saved Queries #### List Saved Queries @@ -217,39 +200,13 @@ Query and analyze your security telemetry using LimaCharlie Query Language (LCQL === "Python" ```python - from limacharlie.client import Client - from limacharlie.sdk.organization import Organization - from limacharlie.sdk.hive import Hive - - client = Client(oid="YOUR_OID", api_key="YOUR_API_KEY") - org = Organization(client) - queries = Hive(org, "query").list() - print(queries) + --8<-- "snippets/python/saved_list.py" ``` === "Go" ```go - package main - - import ( - "fmt" - limacharlie "github.com/refractionPOINT/go-limacharlie/limacharlie" - ) - - func main() { - client, _ := limacharlie.NewClient(limacharlie.ClientOptions{ - OID: "YOUR_OID", - APIKey: "YOUR_API_KEY", - }) - org := limacharlie.NewOrganization(client) - hive := limacharlie.NewHiveClient(org) - queries, _ := hive.List(limacharlie.HiveArgs{ - HiveName: "query", - PartitionKey: "YOUR_OID", - }) - fmt.Println(queries) - } + --8<-- "snippets/golang/saved_list/main.go" ``` === "CLI" @@ -266,57 +223,20 @@ Query and analyze your security telemetry using LimaCharlie Query Language (LCQL curl -s -X POST \ "https://api.limacharlie.io/v1/hive/query/YOUR_OID/my-saved-query/data" \ -H "Authorization: Bearer $LC_JWT" \ - -d data='{"query": "event.FILE_PATH ends with .exe", "stream": "event"}' \ + -d data='{"query": "event/FILE_PATH ends with .exe", "stream": "event"}' \ -d usr_mtd='{"enabled": true}' ``` === "Python" ```python - from limacharlie.client import Client - from limacharlie.sdk.organization import Organization - from limacharlie.sdk.hive import Hive, HiveRecord - - client = Client(oid="YOUR_OID", api_key="YOUR_API_KEY") - org = Organization(client) - Hive(org, "query").set(HiveRecord( - name="my-saved-query", - data={ - "query": "event.FILE_PATH ends with .exe", - "stream": "event", - }, - enabled=True, - )) + --8<-- "snippets/python/saved_create.py" ``` === "Go" ```go - package main - - import ( - limacharlie "github.com/refractionPOINT/go-limacharlie/limacharlie" - ) - - func main() { - client, _ := limacharlie.NewClient(limacharlie.ClientOptions{ - OID: "YOUR_OID", - APIKey: "YOUR_API_KEY", - }) - org := limacharlie.NewOrganization(client) - hive := limacharlie.NewHiveClient(org) - enabled := true - hive.Add(limacharlie.HiveArgs{ - HiveName: "query", - PartitionKey: "YOUR_OID", - Key: "my-saved-query", - Data: limacharlie.Dict{ - "query": "event.FILE_PATH ends with .exe", - "stream": "event", - }, - Enabled: &enabled, - }) - } + --8<-- "snippets/golang/saved_create/main.go" ``` === "CLI" @@ -324,7 +244,8 @@ Query and analyze your security telemetry using LimaCharlie Query Language (LCQL ```bash limacharlie search saved-create \ --name my-saved-query \ - --input-file query.yaml + --query "event/FILE_PATH ends with .exe" \ + --stream event ``` #### Run Saved Query @@ -346,70 +267,19 @@ Query and analyze your security telemetry using LimaCharlie Query Language (LCQL === "Python" ```python - import time - from limacharlie.client import Client - from limacharlie.sdk.organization import Organization - from limacharlie.sdk.hive import Hive - from limacharlie.sdk.search import Search - - client = Client(oid="YOUR_OID", api_key="YOUR_API_KEY") - org = Organization(client) - - saved = Hive(org, "query").get("my-saved-query") - end = int(time.time()) - start = end - 3600 - - for result in Search(org).execute( - query=saved.data["query"], - start_time=start, - end_time=end, - stream=saved.data.get("stream", "event"), - ): - print(result) + --8<-- "snippets/python/saved_run.py" ``` === "Go" ```go - package main - - import ( - "fmt" - limacharlie "github.com/refractionPOINT/go-limacharlie/limacharlie" - ) - - func main() { - client, _ := limacharlie.NewClient(limacharlie.ClientOptions{ - OID: "YOUR_OID", - APIKey: "YOUR_API_KEY", - }) - org := limacharlie.NewOrganization(client) - hive := limacharlie.NewHiveClient(org) - - saved, _ := hive.Get(limacharlie.HiveArgs{ - HiveName: "query", - PartitionKey: "YOUR_OID", - Key: "my-saved-query", - }) - query := saved.Data["query"].(string) - - resp, _ := org.Query(limacharlie.QueryRequest{ - Query: query, - Stream: "event", - }) - for _, r := range resp.Results { - fmt.Println(r) - } - } + --8<-- "snippets/golang/saved_run/main.go" ``` === "CLI" ```bash - limacharlie search saved-run \ - --name my-saved-query \ - --start "$(date -d '1 hour ago' +%s)" \ - --end "$(date +%s)" + limacharlie search saved-run --name my-saved-query ``` #### Delete Saved Query @@ -425,37 +295,13 @@ Query and analyze your security telemetry using LimaCharlie Query Language (LCQL === "Python" ```python - from limacharlie.client import Client - from limacharlie.sdk.organization import Organization - from limacharlie.sdk.hive import Hive - - client = Client(oid="YOUR_OID", api_key="YOUR_API_KEY") - org = Organization(client) - Hive(org, "query").delete("my-saved-query") + --8<-- "snippets/python/saved_delete.py" ``` === "Go" ```go - package main - - import ( - limacharlie "github.com/refractionPOINT/go-limacharlie/limacharlie" - ) - - func main() { - client, _ := limacharlie.NewClient(limacharlie.ClientOptions{ - OID: "YOUR_OID", - APIKey: "YOUR_API_KEY", - }) - org := limacharlie.NewOrganization(client) - hive := limacharlie.NewHiveClient(org) - hive.Remove(limacharlie.HiveArgs{ - HiveName: "query", - PartitionKey: "YOUR_OID", - Key: "my-saved-query", - }) - } + --8<-- "snippets/golang/saved_delete/main.go" ``` === "CLI" @@ -470,4 +316,5 @@ Query and analyze your security telemetry using LimaCharlie Query Language (LCQL - [LCQL Examples](lcql-examples.md) - [Query Console UI](query-console-ui.md) +- [Query Limits & Performance](query-limits-and-performance.md) - [D&R Rules](../3-detection-response/index.md) diff --git a/docs/4-data-queries/lcql-examples.md b/docs/4-data-queries/lcql-examples.md index e4c6f578e..413aedca9 100644 --- a/docs/4-data-queries/lcql-examples.md +++ b/docs/4-data-queries/lcql-examples.md @@ -6,10 +6,56 @@ Got a Unique Query? If you've written a unique query or have one you'd like to share with the community, please join us in the [LimaCharlie Community](https://community.limacharlie.com/)! +## Time Range + +Every LCQL query is scoped to a time range. Where that range comes from depends on the interface you use: + +| Interface | How the time range is set | +|-----------|---------------------------| +| Replay API and raw LCQL query strings | The **first component of the query string**, before the first `\|` (for example `-24h \| ...`). | +| Query Console (web UI) | The **time picker** above the query editor, not the query text. | +| Search API | The **`startTime` and `endTime`** body parameters, in Unix epoch seconds. | +| CLI (`limacharlie search run`) | The **`--start` and `--end`** flags, in Unix epoch seconds. | + +!!! note + In the Search API, the CLI, and the Query Console, the explicit range (the picker, `startTime` / `endTime`, or `--start` / `--end`) always wins: any time prefix written into the query string is stripped and replaced. The examples on this page include the leading time component (`-24h |`) because they are written as raw LCQL, where the time range is the first component of the query string. + +### Time Formats in the Query String + +When the time range is part of the query (raw LCQL and the Replay API), the first component accepts relative durations, absolute date/times, or a bounded range. + +**Relative durations** count backwards from now using Go [duration syntax](https://pkg.go.dev/time#ParseDuration). The units are `h`, `m`, and `s`; there is no day or week unit, so express longer windows in hours (`-168h` is 7 days). + +| Value | Meaning | +|-------|---------| +| `-24h` | Last 24 hours | +| `-90m` | Last 90 minutes | +| `-1h30m` | Last 1 hour and 30 minutes | + +**Absolute date/times** accept common formats such as `2025-01-16 08:52:54` or `2025-01-16`. When you need precise control, include a timezone offset (for example a trailing `Z` or `+02:00`); a time given without one is interpreted as UTC. + +**Bounded ranges** join two values with `to`. Each side may be relative or absolute, and the two can be mixed. A single value with no `to` means "from that time until now". + +```lcql +-24h to -12h | plat == windows | NEW_PROCESS | event/FILE_PATH ends with ".exe" +``` + ## General Queries -Search *all* event types across *all* Windows sytems for a particular string showing up in *any* field. -`-24h | plat == windows | * | event/* contains 'psexec'` +Search *all* event types across *all* Windows systems for a particular string showing up in *any* field. The `event/*` selector is a subtree wildcard: it tests the value against every field in the event. + +```lcql +-24h | plat == windows | * | event/* contains 'psexec' +``` + +!!! warning "`event/*` is powerful but slow" + A subtree wildcard has to test every field of every event, and pairing it with the `*` event-type selector scans every event type - the most expensive shape of query. Use it for broad, exploratory hunts, then narrow to a specific event type and field (for example `NEW_PROCESS | event/COMMAND_LINE contains 'psexec'`) once you know where the value lives. See [Query Limits & Performance](query-limits-and-performance.md#writing-efficient-and-performant-queries). + +You can also scope the wildcard to a specific *subtree* instead of the whole event, which is far cheaper than `event/*`. Windows Event Log records nest many fields under `event/EVENT/EventData`; this matches a username in any of those fields without touching the rest of the event: + +```lcql +-24h | plat == windows | WEL | event/EVENT/EventData/* contains "administrator" +``` ## GitHub Telemetry @@ -19,15 +65,17 @@ GitHub logs can be an excellent source of telemetry to identify potential reposi Show me all the GitHub branch protection override (force pushing to repo without all approvals) in the past 12h that came from a user outside the United States, with the repo, user and number of infractions. -`-12h | plat == github | protected_branch.policy_override | event/public_repo is false and event/actor_location/country_code is not "us" | event/repo as repo event/actor as actor COUNT(event) as count GROUP BY(repo actor)` +```lcql +-12h | plat == github | protected_branch.policy_override | event/public_repo is false and event/actor_location/country_code is not "us" | event/repo as repo event/actor as actor COUNT(event) as count GROUP BY(repo actor) +``` which could result in: | actor | count | repo | |----------|---------|------------------------------------| -| mXXXXXXa | 11 | acmeCorpCodeRep/customers | -| aXXXXXXb | 11 | acmeCorpCodeRep/analysis | -| cXXXXXXd | 3 | acmeCorpCodeRep/devops | +| alice | 11 | example-org/frontend | +| bob | 11 | example-org/analytics | +| carol | 3 | example-org/devops | ## Network Telemetry @@ -37,7 +85,9 @@ Network details recorded on endpoints, such as new connections or DNS requests, Show me all domains resolved by Windows hosts that contain "google" in the last 10 minutes and the number of times each was resolved. -`-10m | plat == windows | DNS_REQUEST | event/DOMAIN_NAME contains 'google' | event/DOMAIN_NAME as domain COUNT(event) as count GROUP BY(domain)` +```lcql +-10m | plat == windows | DNS_REQUEST | event/DOMAIN_NAME contains 'google' | event/DOMAIN_NAME as domain COUNT(event) as count GROUP BY(domain) +``` which could result in: @@ -50,7 +100,9 @@ which could result in: Show me all domains resolved by Windows hosts that contain "google" in the last 10 minutes and the number of unique Sensors that have resolved them. -`-10m | plat == windows | DNS_REQUEST | event/DOMAIN_NAME contains 'google' | event/DOMAIN_NAME as domain COUNT_UNIQUE(routing/sid) as count GROUP BY(domain)` +```lcql +-10m | plat == windows | DNS_REQUEST | event/DOMAIN_NAME contains 'google' | event/DOMAIN_NAME as domain COUNT_UNIQUE(routing/sid) as count GROUP BY(domain) +``` which could result in: @@ -64,15 +116,22 @@ which could result in: ### Unsigned Binaries Grouped and counted. -`-24h | plat == windows | CODE_IDENTITY | event/SIGNATURE/FILE_IS_SIGNED != 1 | event/FILE_PATH as Path event/HASH as Hash event/ORIGINAL_FILE_NAME as OriginalFileName COUNT_UNIQUE(Hash) as Count GROUP BY(Path Hash OriginalFileName)` + +```lcql +-24h | plat == windows | CODE_IDENTITY | event/SIGNATURE/FILE_IS_SIGNED != 1 | event/FILE_PATH as Path event/HASH as Hash event/ORIGINAL_FILE_NAME as OriginalFileName COUNT(event) as Count GROUP BY(Path Hash OriginalFileName) +``` ### Process Command Line Args -`-1h | plat == windows | NEW_PROCESS EXISTING_PROCESS | event/COMMAND_LINE contains "psexec" | event/FILE_PATH as path event/COMMAND_LINE as cli routing/hostname as host` +```lcql +-1h | plat == windows | NEW_PROCESS EXISTING_PROCESS | event/COMMAND_LINE contains "psexec" | event/FILE_PATH as path event/COMMAND_LINE as cli routing/hostname as host +``` ### Stack Children by Parent -`-12h | plat == windows | NEW_PROCESS | event/PARENT/FILE_PATH contains "cmd.exe" | event/PARENT/FILE_PATH as parent event/FILE_PATH as child COUNT_UNIQUE(event) as count GROUP BY(parent child)` +```lcql +-12h | plat == windows | NEW_PROCESS | event/PARENT/FILE_PATH contains "cmd.exe" | event/PARENT/FILE_PATH as parent event/FILE_PATH as child COUNT_UNIQUE(event) as count GROUP BY(parent child) +``` ## Windows Event Log (WEL) @@ -80,29 +139,144 @@ When ingested with EDR telemetry, or as a separate Adapter, `WEL` type events ar ### %COMSPEC% in Service Path -`-12h | plat == windows | WEL | event/EVENT/System/EventID == "7045" and event/EVENT/EventData/ImagePath contains "COMSPEC"` +```lcql +-12h | plat == windows | WEL | event/EVENT/System/EventID == "7045" and event/EVENT/EventData/ImagePath contains "COMSPEC" | event/EVENT/EventData/ImagePath as ImagePath routing/hostname as Host +``` ### Overpass-the-Hash -`-12h | plat == windows | WEL | event/EVENT/System/EventID == "4624" and event/EVENT/EventData/LogonType == "9" and event/EVENT/EventData/AuthenticationPackageName == "Negotiate" and event/EVENT/EventData/LogonProcess == "seclogo"` +```lcql +-12h | plat == windows | WEL | event/EVENT/System/EventID == "4624" and event/EVENT/EventData/LogonType == "9" and event/EVENT/EventData/AuthenticationPackageName == "Negotiate" and event/EVENT/EventData/LogonProcess == "seclogo" | event/EVENT/EventData/TargetUserName as User event/EVENT/EventData/IpAddress as SrcIP routing/hostname as Host +``` ### Taskkill from a Non-System Account #### Requires process auditing to be enabled -`-12h | plat == windows | WEL | event/EVENT/System/EventID == "4688" and event/EVENT/EventData/NewProcessName contains "taskkill" and event/EVENT/EventData/SubjectUserName not ends with "!"` +```lcql +-12h | plat == windows | WEL | event/EVENT/System/EventID == "4688" and event/EVENT/EventData/NewProcessName contains "taskkill" and event/EVENT/EventData/SubjectUserName not ends with "!" | event/EVENT/EventData/NewProcessName as Process event/EVENT/EventData/SubjectUserName as User routing/hostname as Host +``` ### Logons by Specific LogonType -`-24h | plat == windows | WEL | event/EVENT/System/EventID == "4624" AND event/EVENT/EventData/LogonType == "10"` +```lcql +-24h | plat == windows | WEL | event/EVENT/System/EventID == "4624" AND event/EVENT/EventData/LogonType == "10" | event/EVENT/EventData/TargetUserName as User event/EVENT/EventData/IpAddress as SrcIP routing/hostname as Host +``` ### Stack/Count All LogonTypes by User -`-24h | plat == windows | WEL | event/EVENT/System/EventID == "4624" | event/EVENT/EventData/LogonType AS LogonType event/EVENT/EventData/TargetUserName as UserName COUNT_UNIQUE(event) as Count GROUP BY(UserName LogonType)` +```lcql +-24h | plat == windows | WEL | event/EVENT/System/EventID == "4624" | event/EVENT/EventData/LogonType AS LogonType event/EVENT/EventData/TargetUserName as UserName COUNT_UNIQUE(event) as Count GROUP BY(UserName LogonType) +``` ### Failed Logons -`-1h | plat==windows | WEL | event/EVENT/System/EventID == "4625" | event/EVENT/EventData/IpAddress as SrcIP event/EVENT/EventData/LogonType as LogonType event/EVENT/EventData/TargetUserName as Username event/EVENT/EventData/WorkstationName as SrcHostname` +```lcql +-1h | plat == windows | WEL | event/EVENT/System/EventID == "4625" | event/EVENT/EventData/IpAddress as SrcIP event/EVENT/EventData/LogonType as LogonType event/EVENT/EventData/TargetUserName as Username event/EVENT/EventData/WorkstationName as SrcHostname +``` + +--- + +## Common Operators and Patterns + +The filter (the clause before the projection) is a full detection-style expression. These snippets show the operators and patterns you will use most often; combine them with the projection, aggregation, sorting, and limiting clauses shown elsewhere on this page. For the broad `event/*` subtree wildcard, see [General Queries](#general-queries) above. + +### String matching + +Double-quoted values are case-insensitive; single-quoted values are case-sensitive. + +- Contains (case-insensitive): `event/FILE_PATH contains "temp"` +- Contains (case-sensitive): `event/FILE_PATH contains 'Temp'` +- Prefix / suffix: `event/FILE_PATH starts with "c:\\windows"` and `event/FILE_PATH ends with ".exe"` +- Regular expression: `event/COMMAND_LINE matches "(?i)invoke-\\w+"` +- Negation: `event/FILE_PATH not contains "system32"` + +Combined example - executables launched from outside `system32`: + +```lcql +-1h | plat == windows | NEW_PROCESS | event/FILE_PATH ends with ".exe" and event/FILE_PATH not contains "system32" | event/FILE_PATH as Path event/COMMAND_LINE as CommandLine routing/hostname as Host +``` + +### Numeric comparison + +Use `>` / `<` (or the words `is greater than` / `is lower than`): + +```lcql +-1h | * | NETWORK_CONNECTIONS | event/PORT > 1024 +``` + +### IP address and CIDR + +- Match a CIDR range: `event/IP_ADDRESS cidr "10.0.0.0/8"` +- Public vs private address: `event/IP_ADDRESS is public address` (also `is private address`) + +Example - outbound connections to public IPs on high ports: + +```lcql +-1h | * | NETWORK_CONNECTIONS | event/IP_ADDRESS is public address and event/PORT > 1024 | event/IP_ADDRESS as IP event/PORT as Port routing/hostname as Host +``` + +### Field existence + +`exists` matches events where a field is present, regardless of value: + +```lcql +-1h | plat == windows | NEW_PROCESS | event/PARENT/FILE_PATH exists +``` + +### Boolean logic and grouping + +Combine terms with `and`, `or`, and `not`, and use parentheses to control precedence: + +```lcql +-1h | plat == windows | NEW_PROCESS | (event/FILE_PATH ends with "cmd.exe" or event/FILE_PATH ends with "powershell.exe") and event/COMMAND_LINE contains "-enc" +``` + +### Stateful correlation (with child / with descendant / with events) + +Stateful operators match an event only when a *related* event also matches a nested filter (given in parentheses after the operator). They scan the whole time range, so scope them tightly (see [Query Types](query-limits-and-performance.md#query-types)). + +**`with child`** matches when the event has a **direct child** matching the nested filter. For process events, "child" means a directly-spawned process. This query looks for `cmd.exe` directly spawning `calc.exe`: + +```lcql +-6h | plat == windows | NEW_PROCESS | event/FILE_PATH ends with "cmd.exe" with child (event/FILE_PATH ends with "calc.exe") +``` + +Against the process trees below it matches the first but not the second, because there `calc.exe` is a grandchild, not a direct child: + +```text +cmd.exe --> calc.exe (match) +cmd.exe --> firefox.exe --> calc.exe (no match) +``` + +**`with descendant`** works exactly like `with child` but matches at **any depth** (child, grandchild, and deeper). Swapping the operator makes both trees above match: + +```lcql +-6h | plat == windows | NEW_PROCESS | event/FILE_PATH ends with "cmd.exe" with descendant (event/FILE_PATH ends with "calc.exe") +``` + +**`with events`** correlates **proximal events on the same sensor** that need not be in a parent/child relationship - it simply requires that another event matching the nested filter also occurred. This example flags a host that ran a credential-dumping command line and, separately, a lateral-movement tool: + +```lcql +-6h | plat == windows | NEW_PROCESS | event/COMMAND_LINE contains "sekurlsa" with events (event/COMMAND_LINE contains "psexec") +``` + +!!! tip "Repetition thresholds (count / within)" + To match *repeated* events - for example 5 failed logons within 60 seconds - use the `count` and `within` modifiers. Those are available in [D&R stateful rules](../3-detection-response/stateful-rules.md) (YAML), which use the same `with child` / `with descendant` / `with events` model and include additional sample data. + +### Target specific sensors + +The sensor field accepts `*` (whole org), a [Sensor Selector](../8-reference/sensor-selector-expressions.md) expression, or a space-separated list of sensor IDs: + +```lcql +-1h | 1a2b3c4d-1111-2222-3333-444455556666 5f6e7d8c-9999-8888-7777-666655554444 | NEW_PROCESS | event/FILE_PATH ends with ".exe" +``` + +!!! note "Aggregation functions" + LCQL provides two aggregation functions: `COUNT(...)` (number of matching rows) and `COUNT_UNIQUE(...)` (number of distinct values of a field). There is no `SUM`, `AVG`, `MIN`, or `MAX`. + + - Do not wrap a field in `COUNT_UNIQUE` that is also a `GROUP BY` key - the result is always 1. + - Avoid `GROUP BY` on high-cardinality fields (for example a full command line, a file hash, or a raw timestamp). It produces a very large number of groups, is inefficient, and rarely yields useful insight - group by a coarser field instead. --- @@ -117,7 +291,7 @@ ORDER BY( [asc|desc]) ORDER BY() # direction omitted; defaults to ascending ``` -The parentheses are mandatory — they delimit the operator's arguments inside the space-delimited projection clause. Direction keywords are case-insensitive but the canonical form is lowercase `asc` / `desc`. Sort keys may reference either raw selectors (e.g. `event/PORT`) or projection aliases (e.g. `Port`). +The parentheses are mandatory - they delimit the operator's arguments inside the space-delimited projection clause. Direction keywords are case-insensitive but the canonical form is lowercase `asc` / `desc`. Sort keys may reference either raw selectors (e.g. `event/PORT`) or projection aliases (e.g. `Port`). !!! note `ORDER BY` currently sorts on a single key. Multi-key sort expressions are not supported by the backend at this time. @@ -134,13 +308,17 @@ LIMIT Sort raw events by a numeric field, no aggregation: -`-1h | * | NETWORK_CONNECTIONS | event/PORT > 1000 | event/IP_ADDRESS as IP event/PORT as Port ORDER BY(Port desc) LIMIT 100` +```lcql +-1h | * | NETWORK_CONNECTIONS | event/PORT > 1000 | event/IP_ADDRESS as IP event/PORT as Port ORDER BY(Port desc) LIMIT 100 +``` ### Top 50 Failed-Logon Source IPs Sort an aggregated count, descending: -`-24h | plat == windows | WEL | event/EVENT/System/EventID == "4625" | event/EVENT/EventData/IpAddress as SourceIP COUNT(event) as FailedAttempts GROUP BY(SourceIP) ORDER BY(FailedAttempts desc) LIMIT 50` +```lcql +-24h | plat == windows | WEL | event/EVENT/System/EventID == "4625" | event/EVENT/EventData/IpAddress as SourceIP COUNT(event) as FailedAttempts GROUP BY(SourceIP) ORDER BY(FailedAttempts desc) LIMIT 50 +``` --- @@ -148,4 +326,5 @@ Sort an aggregated count, descending: - [LCQL Overview](index.md) - [Query Console](query-console-ui.md) +- [Query Limits & Performance](query-limits-and-performance.md) - [EDR Events](../8-reference/edr-events.md) diff --git a/docs/4-data-queries/query-console-ui.md b/docs/4-data-queries/query-console-ui.md index 40879a7d7..90e30495f 100644 --- a/docs/4-data-queries/query-console-ui.md +++ b/docs/4-data-queries/query-console-ui.md @@ -8,57 +8,72 @@ To view and operate the Query Console, the following permissions are required: ### UI Element Overview -![Query console permissions requirement screen](../assets/images/image(338).png) +![Annotated overview of the Query Console interface with numbered UI elements](../assets/images/query-console-overview.png) 1. **Source:** Select Events (everything that had been injected from endpoints and XDR sources, default), Detections, or Platform Audit events as the data source for the search. + 2. **Query editor:** Enter a LimaCharlie Query Language (LCQL) query to include: - 1. *Sensor Selector -* precisely define the sensors that produced the desired events. - 2. *Event Type* - filter results to only return specific types of events. - 3. Filter - the actual query filter using individual fields and operations on top of them. - 4. Projections (optional) - control output columns, sort results via `ORDER BY` and/or aggregate the data with `GROUP BY` , `COUNT`, `COUNT_UNIQUE` and more. See LCQL reference and Examples for details. + 1. *Sensor Selector -* precisely define the sensors that produced the desired events. + 2. *Event Type* - filter results to only return specific types of events. + 3. Filter - the actual query filter using individual fields and operations on top of them. + 4. Projections (optional) - control output columns, sort results via `ORDER BY` and/or aggregate the data with `GROUP BY` , `COUNT`, `COUNT_UNIQUE` and more. See LCQL reference and Examples for details. + 3. **Time period:** Set the searchable time period using three options: last [time period], around [time frame], and absolute "from start→to finish". - ![Event Type - filter results to only return specific types of events](../assets/images/image(340).png) + ![Event Type - filter results to only return specific types of events](../assets/images/image(340).png) + + - Enter a time `16:00`, or day and time `2025-01-16 08:52:54`, using most common time formats. For example: - - Enter a time `16:00`, or day and time `2025-01-16 08:52:54`, using most common time formats. For example: + - From `33m` to `now` - last 33 minutes + - Around `2025-01-16 08:52:54` +- `15 minutes` - 15 minutes before and after the specified time stamp + - From `10am` to `1:30pm` - - From `33m` to `now` - last 33 minutes - - Around `2025-01-16 08:52:54` +- `15 minutes` - 15 minutes before and after the specified time stamp - - From `10am` to `1:30pm` + **Note:** All times are shown according to the timezone selected by the user in User Settings. - **Note:** All times are shown according to the timezone selected by the user in User Settings. 4. **Available Fields:** Managed data exploration - 1. Schema fields - a list of all the fields associated with ingested events. - 2. Event types - event types present in the returned portion of the query. As more data is churned to complete the specified time frame more event types may appear. - 3. Query fields - event fields present in the *portion of the result already fetched by the query*, with a count of total occurrences. Clicking on the event field opens a details panel. From here you can add a term to the query. + 1. Schema fields - a list of all the fields associated with ingested events. + 2. Event types - event types present in the returned portion of the query. As more data is churned to complete the specified time frame more event types may appear. + 3. Query fields - event fields present in the *portion of the result already fetched by the query*, with a count of total occurrences. Clicking on the event field opens a details panel. From here you can add a term to the query. - ![Schema fields - a list of all the fields associated with ingested events](../assets/images/image(341).png) - 4. Table columns: control the columns displayed in Table View. + ![Schema fields - a list of all the fields associated with ingested events](../assets/images/image(341).png) + + 4. Table columns: control the columns displayed in Table View. + + Note: While the schema fields are always available, the event types and query fields are only shown for portion of the time frame *searched so far*. As more data is churned in the background (to complete your selected time frame), more event types and fields may appear. - Note: While the schema fields are always available, the event types and query fields are only shown for portion of the time frame *searched so far*. As more data is churned in the background (to complete your selected time frame), more event types and fields may appear. 5. **Query status:** Shows the state of your query in real time, highlighting any existing syntax errors or providing a cost estimate if the query is properly formed. - As the query runs the status displays progress, query status, and a running total of the cost accrued. + As the query runs the status displays progress, query status, and a running total of the cost accrued. + + *Query cost estimation:* Queries are charged by the amount of data churned, measured and billed per 200,000 events evaluated. This estimation shows the "at most" cost of a query for the selected time range. Only retrieved data is chargeable. - *Query cost estimation:* Queries are charged by the amount of data churned, measured and billed per one million events evaluated. This estimation shows the "at most" cost of a query for the selected time range. Only retrieved data is chargeable. + *Performance tuning:* The better tuned the query, the faster the search and lower the cost. Using Sensor Selector and Event Type to precisely target the desired telemetry will increase search speeds and lower costs. - *Performance tuning:* The better tuned the query, the faster the search and lower the cost. Using Sensor Selector and Event Type to precisely target the desired telemetry will increase search speeds and lower costs. 6. **Histogram:** - When a search is run, a histogram appears below the query field showing the distribution of events over time. The portion with a vertical bar chart represents results that have been retrieved so far. The non-bar chart portion shows the total number of events in the selected time frame. The histogram shows the progress of the search through the time frame. As you paginate through the search, more events are evaluated, and more bars appear to signify the progress through the time frame. + When a search is run, a histogram appears below the query field showing the distribution of events over time. The portion with a vertical bar chart represents results that have been retrieved so far. The non-bar chart portion shows the total number of events in the selected time frame. The histogram shows the progress of the search through the time frame. As you paginate through the search, more events are evaluated, and more bars appear to signify the progress through the time frame. + 7. **Search results:** displays results in two views, **timeline** and **table**. Timeline view shows matching events with the most recent on top. Table view provides a way to sort results into desired columns. Find the desired field in Query Fields and use the `pin` icon to add it as a column. - 1. A **Tab Columns** section appears in the **Fields** sidebar when table view is selected. Columns can be viewed or removed here. - 2. **Event Details** allows you to click on an event and perform applicable event actions like **Build a D&R Rule**. - 3. **Download** all the events you've retrieved in a [.ndjson format](https://github.com/ndjson/ndjson-spec). The automatic download of the entire time range is coming soon. + 1. A **Tab Columns** section appears in the **Fields** sidebar when table view is selected. Columns can be viewed or removed here. + 2. **Event Details** allows you to click on an event and perform applicable event actions like **Build a D&R Rule**. + 3. **Download** all the events you've retrieved in a [.ndjson format](https://github.com/ndjson/ndjson-spec). The automatic download of the entire time range is coming soon. + + ![A Tab Columns section appears in the Fields sidebar when table view is selected](../assets/images/image(342).png) - ![A Tab Columns section appears in the Fields sidebar when table view is selected](../assets/images/image(342).png) 8. **Saving Queries and Query Library.** A query can be saved in your private user library or shared via an org library. Use the library to browse queries and load the desired one to the query editor. +9. **Progress indicator:** The status line shows how much of the query has completed so far (for example, `11% scanned`). For whole-timeline queries such as aggregations, sorting, and other stateful operations, this value climbs as more of the selected time range is processed. See [Query Limits & Performance](query-limits-and-performance.md#query-progress-and-cost-reporting) for details on how progress and cost are reported. + +10. **Search details (info icon):** Hovering the info icon at the end of the status line opens a **Search Details** panel with per-session and per-page timings (wall, server, total work, cost, and pages) and a completion breakdown (progress, batches completed vs. in scope, events, and data). It also shows the query's **Query ID**, which you can copy and share with LimaCharlie support when reporting an issue with a query so troubleshooting is faster. + + ![Search Details panel showing per-session and per-page timings, a completion breakdown, and the Query ID](../assets/images/query-console-search-details.png) + --- ### What's Next - [LimaCharlie Query Language](lcql-examples.md) +- [Query Limits & Performance](query-limits-and-performance.md) diff --git a/docs/4-data-queries/query-limits-and-performance.md b/docs/4-data-queries/query-limits-and-performance.md new file mode 100644 index 000000000..9f4e154c3 --- /dev/null +++ b/docs/4-data-queries/query-limits-and-performance.md @@ -0,0 +1,246 @@ +# Query Limits & Performance + +This page describes the operational limits that apply to Query Console and LCQL searches - how many queries you can run at once and how long a query may run - along with guidance on how large an aggregation can reasonably get and how to write efficient queries that stay within those limits and cost less. It also covers the different query types, since how a query executes determines how it behaves against these limits. + +## Query Types + +How a query executes - and therefore how it behaves against the limits below - depends on what it does. LCQL queries fall into four kinds: + +| Query type | What it does | Execution | +|------------|--------------|-----------| +| **Stateless** | Evaluates each event independently against the filter and returns the matching events. This is the default. | Paged: results come back a page at a time, and you fetch more on demand. | +| **Projection** | Adds a projection clause at the end of the query to return only selected or renamed fields instead of whole events. | Paged, as long as it only selects or renames fields; `GROUP BY`, `ORDER BY`, or an aggregation function make it whole-timeline. | +| **Aggregation** | Uses aggregation functions in the projection (`COUNT`, `COUNT_UNIQUE`, `GROUP BY`, and similar) to summarize matching events. | Whole timeline: the entire selected time range is scanned before any result is returned. | +| **Stateful** | Uses a filter that correlates across events, such as `with child`, so a match depends on more than one event. | Whole timeline: the entire selected time range is scanned before any result is returned. | + +**Paged** queries (a stateless filter, or a projection that only selects fields) stream results incrementally and rarely run long. **Whole-timeline** queries (anything that sorts, groups, aggregates, or correlates across events) must scan the full range before returning, so they consume the most resources and are the ones that can reach the [query timeout](#query-timeouts). + +## Data Sources (Streams) + +Every query runs against one data *stream*, chosen with the Source dropdown in the Query Console or the `stream` parameter in the API, CLI, and SDKs. The stream determines which kind of records the query scans: + +| Stream | Console label | Contains | +|--------|---------------|----------| +| `event` | Events | Raw telemetry collected from endpoints, adapters, and other sensors. This is the default. | +| `detection` | Detections | Detections produced by your D&R rules. | +| `audit` | Platform Audit | Platform audit records, such as configuration changes and user actions. | + +A query only sees data from the stream it targets - a query on the `event` stream will not match detections, and vice versa. When the `stream` parameter is omitted it defaults to `event`. If a query returns nothing you expected to see, confirm you are searching the intended stream. + +## Concurrent Queries + +Each organization can run several queries at the same time. Every organization is guaranteed a minimum of **10 concurrent queries**, and the effective limit may be higher depending on your region and plan. + +Both interactive Query Console searches and searches issued through the API, CLI, or SDKs count toward this limit. A paginated query counts as active for the entire time it is fetching pages, not only at the moment it starts. + +When the limit is reached, additional queries are rejected with an `HTTP 429` (too many concurrent queries) response until one of the in-flight queries finishes. Retry the rejected query once an earlier one completes. + +!!! tip + If you regularly run automation or dashboards that need more headroom, contact support to request a higher concurrent-query limit for your organization. + +## Query Timeouts + +A single query has a maximum execution time of roughly **8 to 9 minutes**. If a query exceeds this deadline it returns an error rather than partial results. + +**Paged queries (a stateless filter or a field-only projection).** Each page fetches a bounded number of events and returns quickly, so paged queries should effectively never reach the timeout. When you need more results, fetch the next page rather than trying to widen a single request. + +**Whole-timeline queries (sorting, aggregation, and stateful).** Sorting (`ORDER BY`), aggregations (`COUNT`, `COUNT_UNIQUE`, `GROUP BY`), and stateful filters (such as `with child`) must scan the entire selected time range before they can return results, because the outcome is only complete once every matching event has been evaluated. Over a very large time range or a high volume of data, the scan can exceed the timeout and the query returns an error. See [Query Types](#query-types) for the distinction. + +!!! note "Working around whole-timeline timeouts" + If a large aggregation times out, narrow the time range and split the work into several smaller queries that each cover an incremental slice of the range, then combine the results yourself. + + For example, instead of a single 24-hour aggregation, run the same aggregation over 24 consecutive one-hour windows and add the per-window counts together for the full-range total. Keep the query identical and only change the time range for each run: + + ```lcql + plat == windows | WEL | event/EVENT/System/EventID == "4625" | COUNT(event) as FailedAttempts + ``` + + Each one-hour window stays well under the timeout. Set the time range per run using the Console time picker (absolute from/to), the CLI `set_time`, or the API `startTime` / `endTime` parameters. + + Splitting and summing works for additive aggregations like `COUNT`; a `COUNT_UNIQUE` result cannot simply be added across windows. For stateful queries (`with child`), narrow the scope instead, since splitting can miss correlations that span a window boundary. + +## Aggregation Limits + +Aggregations build in-memory groupings as they scan, so very high-cardinality aggregations become slow and unreliable. Treat the following as recommended guardrails for dependable results: + +- `GROUP BY` distinct groups: keep well under **~1,000,000** distinct groups. +- `COUNT_UNIQUE` distinct values per field per group: keep well under **~5,000,000** distinct values. + +The usual cause of blowing past these numbers is grouping by a near-unique field (see [Anti-patterns](#anti-patterns) below). Group by a coarser field, or narrow the scope, so the number of groups stays bounded. + +!!! tip + Add `ORDER BY(...) LIMIT N` to bound the output, and project only the fields you need to shrink each row. See [Writing Efficient and Performant Queries](#writing-efficient-and-performant-queries) below. + +## Query Progress and Cost Reporting + +Because a query can scan a large amount of data, the API reports both a pre-flight estimate before you run it and the actual progress and cost as results stream back. A query scans stored telemetry in discrete units called *batches*; the batch counts below are what drive a progress bar. + +### Pre-flight estimate (validate) + +The [validate endpoint](index.md#validate-query-syntax) returns an estimate of how much work a query represents before you run it: + +- `batchesInScope` - the total number of batches the query will scan. This is the denominator for a progress bar. +- `eventsInScope` / `bytesInScope` - the estimated number of events and bytes in scope. +- `estimatedPrice` - the estimated cost, derived from the events in scope. + +### Progress while paging + +Each page of a running search reports how much of the query is complete so far in its `cumulativeStats`: + +- `batchesInScope` - the total batches in scope (denominator); the same value for every page of the search. +- `batchesCompleted` - the batches processed so far across all pages (numerator). + +Render progress as `batchesCompleted / batchesInScope`, clamped to 0-100%. This is exactly how the Query Console progress bar is computed. The per-page `batchesProcessed` field reports the batches handled by that single page. Byte- and event-weighted ratios (`bytesScanned / bytesInScope`, `eventsScanned / eventsInScope`) are also available as a smoother signal, but the batch ratio is the reliable one; guard against a zero denominator. + +### Actual cost per page + +Every page also returns the actual billing for the data it processed, so you do not have to trust the estimate for cost: + +- `billedEvents` / `freeEvents` - the events on this page that were billed versus covered by a free-tier window (`billedEvents + freeEvents == eventsScanned`). +- `estimatedPrice` - the price for this page, derived from the actual `billedEvents`. The running totals across all pages are carried in `cumulativeStats`. + +!!! warning "Estimates are approximate - rely on the per-page billing for cost" + The pre-flight `estimatedPrice`, `eventsInScope`, and related validate estimates are approximations. Their accuracy varies with the query type and with internal optimizations that reduce how much data actually has to be scanned, which are not always reflected in the estimate. Treat the estimate as a planning aid only and never rely on it as the exact cost. The authoritative cost is the actual billing (`billedEvents` and the `estimatedPrice` derived from it) returned with each page and accumulated in `cumulativeStats`. + +### Building a Progress Bar + +The Query Console renders its progress bar with this formula: + +```text +progress = clamp(batchesCompleted / batchesInScope, 0, 100%) +``` + +Use `batchesInScope` as the denominator - from the [validate response](#pre-flight-estimate-validate) before the search starts, or from each page's `cumulativeStats` once it is running - and the per-page `cumulativeStats.batchesCompleted` as the numerator. Two rules keep the bar well-behaved: + +- **Guard the denominator.** `batchesInScope` is `0` (or absent) until the scope is known, so treat progress as unavailable rather than dividing by zero. +- **Clamp the ratio.** `batchesCompleted` can briefly exceed `batchesInScope` when a batch is re-opened across page boundaries, so clamp to 100%. A page's `completed` flag is the authoritative "done" signal. + +The examples below take one Search API page response (a parsed `SearchResponse`) and return a percentage in the range 0-100. + +=== "Python" + + ```python + --8<-- "snippets/python/progress_bar.py" + ``` + +=== "Go" + + ```go + --8<-- "snippets/golang/progress_bar/main.go" + ``` + +=== "Bash (curl + jq)" + + ```bash + # Denominator only, from the pre-flight validate response (before running): + curl -s -X POST "https://$SEARCH_HOST/v1/search/validate" \ + -H "Authorization: Bearer $LC_JWT" -H "Content-Type: application/json" \ + -d '{"oid":"YOUR_OID","query":"...","startTime":"'"$START"'","endTime":"'"$END"'"}' \ + | jq '.stats.batchesInScope' + + # Progress from a running search page: clamp batchesCompleted/batchesInScope + # to 0-100%, and treat a completed page as 100%. + curl -s -X POST "https://$SEARCH_HOST/v1/search" \ + -H "Authorization: Bearer $LC_JWT" -H "Content-Type: application/json" \ + -d '{"oid":"YOUR_OID","query":"...","startTime":"'"$START"'","endTime":"'"$END"'","stream":"event"}' \ + | jq ' + ([.results[].stats.cumulativeStats | select(. != null)] | first) as $c + | if .completed then 100 + elif ($c.batchesInScope // 0) > 0 + then ([100 * $c.batchesCompleted / $c.batchesInScope, 100] | min) + else 0 + end' + ``` + +See [Run an LCQL Query](index.md#run-an-lcql-query) for how to discover `$SEARCH_HOST` and obtain a JWT. + +## Writing Efficient and Performant Queries + +Query cost is measured by the amount of data churned (billed per 200,000 events evaluated), and speed tracks the same factor: the fewer events a query has to scan and the less data it has to return, the faster and cheaper it is. The patterns below reduce both. + +### Prefer Projections (Select Only the Fields You Need) + +By default a query returns whole events. Adding a projection clause (the segment after the final `|`) returns only the fields you name, which reduces the data transferred, speeds up the query, and lowers cost. + +Non-aggregation query. Instead of returning every field of each matching event: + +```lcql +-1h | * | NETWORK_CONNECTIONS | event/PORT > 1000 +``` + +project just the two fields you actually care about: + +```lcql +-1h | * | NETWORK_CONNECTIONS | event/PORT > 1000 | event/IP_ADDRESS as IP event/PORT as Port +``` + +Aggregation query. Projections also define what an aggregation emits. This returns only the source IP and its failed-logon count, sorted and capped: + +```lcql +-24h | plat == windows | WEL | event/EVENT/System/EventID == "4625" | event/EVENT/EventData/IpAddress as SourceIP COUNT(event) as FailedAttempts GROUP BY(SourceIP) ORDER BY(FailedAttempts desc) LIMIT 50 +``` + +### Narrow the Scope Early + +Restrict what the query has to scan before it reaches the filter: + +- Use the [Sensor Selector](../8-reference/sensor-selector-expressions.md) instead of `*` so only relevant sensors are searched (see below). +- Set the Event Type to the specific events you need rather than searching all event types. +- Use the tightest time range that answers your question. + +Each of these lowers the number of events churned, which makes the query both faster and cheaper. + +**Targeting sensors by ID.** When you know exactly which sensors you care about, matching on `sid` is the most efficient selector of all, because it narrows the scan to specific sensors before any events are read: + +- A single sensor: `sid == ""`. +- A specific set of sensors: combine terms with `or`, as in `sid == "" or sid == "" or sid == ""`. + +When you do not know the IDs, select by attribute instead - for example `plat == windows`, `"prod" in tags`, or by `hostname`. Sensor IDs are UUIDs, and a selector value that starts with a number must be backtick-quoted; see the [Sensor Selector reference](../8-reference/sensor-selector-expressions.md) for the full operator list and quoting rules. + +### Bound Output with ORDER BY and LIMIT + +For "top N" style questions, always add `ORDER BY(...) LIMIT N` so the result set is capped instead of returning every matching row. See [Sorting and Limiting Results](lcql-examples.md#sorting-and-limiting-results) for the full syntax. + +### Aggregate Instead of Pulling Raw Events + +When you only need counts or summaries, use `COUNT`, `COUNT_UNIQUE`, and `GROUP BY` rather than downloading raw events and counting them yourself. Aggregating in the query returns a small summary instead of a large event stream. + +### Split Large Aggregations + +If an aggregation over a wide time range is slow or times out, break it into smaller incremental time windows and combine the results, as described in [Working around whole-timeline timeouts](#query-timeouts) above. + +### Anti-patterns + +!!! warning "Avoid these patterns" + - `*` sensor selector with no Event Type filter over a wide time range - this scans everything and is the slowest, most expensive shape of query. + - Returning whole events when you only need a few fields - add a projection instead. + - Grouping by a near-unique field such as a full command line, a raw timestamp, or a per-event identifier - this produces millions of groups and blows past the aggregation guardrails. Group by a coarser field. + - Unbounded aggregations with no `LIMIT` - cap the output with `ORDER BY(...) LIMIT N`. + +## Troubleshooting + +### The query is rejected before it runs + +[Validate the query](index.md#validate-query-syntax) first - the validate endpoint reports syntax errors without scanning any data. Common causes: + +- **Field paths use `/`, not dots.** Write `event/FILE_PATH`, not `event.FILE_PATH`. Nested fields chain with slashes, as in `event/PARENT/FILE_PATH`. +- **A selector value that starts with a number must be backtick-quoted**, for example `` plat == `1password` ``. +- **Projection and aggregation go in the final clause**, after the last `|`. See [LCQL Examples](lcql-examples.md). + +### The query returns no results + +- **Wrong stream.** A query only sees the stream it targets. If you expected detections, query the `detection` stream rather than `event`. See [Data Sources (Streams)](#data-sources-streams). +- **Time range.** Confirm the range actually covers the data. In the Query Console, times use the timezone from your User Settings; API, CLI, and SDK times are Unix epoch seconds. +- **Selector too narrow.** An overly specific Sensor Selector or Event Type can exclude the data you want - widen it and re-run. +- **Field name.** A misspelled or non-existent field simply never matches. Use the Available Fields panel or the [event schema](../8-reference/event-schemas.md) to confirm field names. + +### The query is rejected as too busy or times out + +- **`HTTP 429` (too many concurrent queries).** You have reached the [concurrent-query limit](#concurrent-queries). Wait for an in-flight query to finish, then retry. +- **Timeout.** Long-running aggregations over large ranges can hit the [query timeout](#query-timeouts). Narrow the range or split the query into smaller windows. + +## See Also + +- [LCQL Examples](lcql-examples.md) +- [Query Console UI](query-console-ui.md) +- [Query with CLI](query-cli.md) diff --git a/docs/assets/images/query-console-overview.png b/docs/assets/images/query-console-overview.png new file mode 100644 index 000000000..9654c3c2a Binary files /dev/null and b/docs/assets/images/query-console-overview.png differ diff --git a/docs/assets/images/query-console-search-details.png b/docs/assets/images/query-console-search-details.png new file mode 100644 index 000000000..4e40d644d Binary files /dev/null and b/docs/assets/images/query-console-search-details.png differ diff --git a/hooks/lcql_lexer.py b/hooks/lcql_lexer.py new file mode 100644 index 000000000..059612692 --- /dev/null +++ b/hooks/lcql_lexer.py @@ -0,0 +1,119 @@ +"""MkDocs hook: register a Pygments lexer for LimaCharlie Query Language (LCQL). + +This enables ```lcql fenced code blocks to be syntax-highlighted by +``pymdownx.highlight`` (which resolves languages through +``pygments.lexers.get_lexer_by_name``). + +The lexer is injected directly into Pygments' in-process registry rather than +shipped as an installed entry-point package: we pre-populate the lexer cache +under the class name and add a matching entry to the alias mapping, so +``get_lexer_by_name("lcql")`` returns it without importing anything. Registration +runs both at import time and in ``on_config`` so it is in place before any page +is rendered, regardless of hook-loading order. + +Contract: highlighting is purely cosmetic. If registration ever fails, +``pymdownx.highlight`` falls back to plain (unhighlighted) text, so a ```lcql +block never breaks the build. +""" + +from pygments.lexer import RegexLexer, words +from pygments.token import ( + Keyword, + Name, + Number, + Operator, + Punctuation, + String, + Text, + Whitespace, +) + + +class LCQLLexer(RegexLexer): + """Pygments lexer for LimaCharlie Query Language (LCQL). + + Tokenizes the five-part pipe structure (time | sensor selector | event + types | filter | projection): field paths (``event/...``, ``routing/...``), + string/number literals, comparison and stateful operators, boolean + keywords, aggregation functions, and the projection keywords. The final + catch-all rule guarantees the lexer never emits an error token. + """ + + name = "LCQL" + aliases = ["lcql"] + filenames: list[str] = [] + + # Word-form operators and keywords from the filter, boolean, and stateful + # grammar, plus "in" (bexpr sensor selector) and "to" (time range). Multi-word + # operators such as "starts with" are matched as individual words. + _keywords = ( + "and", "or", "not", "with", "child", "descendant", "events", + "contains", "starts", "ends", "matches", "is", "cidr", "exists", + "public", "private", "address", "ipv4", "ipv6", "platform", + "tagged", "scope", "in", "to", "greater", "lower", "than", + ) + # Projection / sorting / limiting keywords. + _projection = ("AS", "GROUP", "BY", "ORDER", "LIMIT", "asc", "desc") + # Aggregation functions (longest first so COUNT_UNIQUE wins over COUNT). + _aggregations = ("COUNT_UNIQUE", "COUNT") + + tokens = { + "root": [ + (r"\s+", Whitespace), + (r"\|\|", Operator), + (r"\|", Punctuation), + (r"[()]", Punctuation), + (r'"[^"]*"', String.Double), + (r"'[^']*'", String.Single), + # Sensor IDs (UUIDs) before the number rule so they stay intact. + (r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", + Name.Constant), + (words(_aggregations, suffix=r"(?=\()"), Name.Function), + (words(_projection, prefix=r"\b", suffix=r"\b"), Keyword.Declaration), + (words(_keywords, prefix=r"\b", suffix=r"\b"), Keyword), + (r"==|!=|>|<|&&|!|\*", Operator), + # Relative time durations, including compound Go durations, e.g. + # -24h, -10m, -1h30m, -1h30m45s. (Bare integers are matched below.) + (r"-?(?:\d+(?:\.\d+)?(?:ns|us|ms|[smhdwy]))+", Number), + # Field paths containing at least one slash segment. + (r"[A-Za-z_][A-Za-z0-9_]*(?:/[A-Za-z0-9_*?]+(?:\[\d+\])?)+", + Name.Variable), + # Bare identifiers: event types, platform values, aliases. + (r"[A-Za-z_][A-Za-z0-9_.]*", Name), + (r"\d+", Number), + # Catch-all so an unexpected character never produces an error token. + (r".", Text), + ], + } + + +def _register() -> None: + """Make ``get_lexer_by_name("lcql")`` resolve to :class:`LCQLLexer`. + + Wrapped by callers in a broad ``except`` so that any failure (for example a + future Pygments dropping the private ``_lexer_cache`` symbol this relies on) + degrades to unhighlighted ``lcql`` blocks instead of breaking the build. + """ + from pygments.lexers import LEXERS, _lexer_cache + + _lexer_cache["LCQL"] = LCQLLexer + # (module, class display name, aliases, filename globs, mimetypes). The + # module is empty because the class is already cached, so no import occurs. + LEXERS["LCQLLexer"] = ("", "LCQL", ("lcql",), (), ()) + + +try: + _register() +except Exception: + # Fail safe: if registration is not possible, an ``lcql`` block simply + # renders as plain text and the build still succeeds. + pass + + +def on_config(config): + """MkDocs hook entry point; re-register defensively before pages render.""" + try: + _register() + except Exception: + pass + return config diff --git a/mkdocs.yml b/mkdocs.yml index d411ea54f..ccc0af496 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -5,6 +5,11 @@ site_author: refractionPOINT repo_url: https://github.com/refractionPOINT/documentation repo_name: refractionPOINT/documentation +# Treat build warnings (broken nav entries, unresolved internal links, bad +# redirects) as errors on every build path - deploy included - so a regression +# fails fast instead of shipping. The PR docs-preview already builds --strict. +strict: true + theme: name: material custom_dir: overrides @@ -142,6 +147,7 @@ plugins: hooks: - hooks/llms_txt.py + - hooks/lcql_lexer.py markdown_extensions: - abbr @@ -172,7 +178,9 @@ markdown_extensions: - pymdownx.mark - pymdownx.smartsymbols - pymdownx.snippets: - base_path: docs + base_path: + - docs + - . auto_append: - includes/glossary.md - pymdownx.superfences: @@ -357,6 +365,7 @@ nav: - LCQL Examples: 4-data-queries/lcql-examples.md - Query Console UI: 4-data-queries/query-console-ui.md - Query CLI: 4-data-queries/query-cli.md + - Query Limits & Performance: 4-data-queries/query-limits-and-performance.md - Template Strings: 4-data-queries/template-strings.md - Template Transforms: 4-data-queries/template-transforms.md - Events: diff --git a/scripts/check-list-numbering.py b/scripts/check-list-numbering.py new file mode 100644 index 000000000..e74750516 --- /dev/null +++ b/scripts/check-list-numbering.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +"""Detect ordered lists whose numbering visibly restarts due to fragmentation. + +Python-Markdown (what MkDocs renders with) requires 4-space indentation to keep +content inside a list item. When continuation content (an image, code block, or +paragraph) under an item is indented by less than that, the item ends early and +the list splits into several sibling ``
    `` blocks. Python-Markdown emits no +``start=`` attribute, so every fragment restarts at 1 - the visible "wacky +numbering" bug (e.g. a step the author wrote as "6." rendering as a fresh "1."). + +Why this check is free of false positives +----------------------------------------- +The hard part is telling an ACCIDENTAL fragmentation from two INTENTIONAL +adjacent ordered lists - both look identical in the rendered HTML. The tie +breaker is the author's own numbering in the Markdown source: + + * intentional second list -> the author wrote "1." (a deliberate restart) + * accidental fragmentation -> the author wrote "2." / "3." / ... (renders as 1) + +So a fragment is reported ONLY when its first item's text maps back to a source +ordered marker whose number is greater than 1 *everywhere it appears*. An item +the author numbered >1 must never be the first item of its rendered list, so a +hit is unambiguous. Fragments whose first item has no matchable text (it starts +with an image or code block) are skipped rather than guessed - the check would +rather miss an ambiguous case than raise a false alarm. + +This deliberately does NOT flag: + * lists written entirely with "1." markers relying on auto-numbering (genuinely + ambiguous - could be one list or several intentional ones), or + * nested lists that merely flatten while keeping sequential numbers. +Those need human judgement; a heuristic for them produces false positives. + +Usage +----- +Run after ``mkdocs build`` (it reads the rendered ``site/`` and the ``docs/`` +source): + + python scripts/check-list-numbering.py # docs/ + site/ + python scripts/check-list-numbering.py docs site # explicit paths + +Options: + --baseline FILE Ignore findings listed in FILE (known, accepted debt); + exit non-zero only on findings NOT in the baseline. + --update-baseline FILE Write the current findings to FILE and exit 0. + +Exit code is 1 when any non-baselined numbering break is found, else 0. +""" +import argparse +import json +import os +import re +import sys +import glob +from html.parser import HTMLParser + +VOID = {'img', 'br', 'hr', 'input', 'meta', 'link', 'source', 'area', 'col'} +HEADINGS = {'h1', 'h2', 'h3', 'h4', 'h5', 'h6'} +FENCE = re.compile(r'^\s*(`{3,}|~{3,})') +ORDERED = re.compile(r'^\s*(\d+)[.)]\s+(.*)') + + +def normalize(text): + """Reduce item text to a stable key for matching source against rendered.""" + text = text.lower() + text = re.sub(r'\[([^\]]*)\]\([^)]*\)', r'\1', text) # [label](url) -> label + text = re.sub(r'[`*_~]+', '', text) # strip inline marks + text = re.sub(r'[^a-z0-9 ]+', ' ', text) + return re.sub(r'\s+', ' ', text).strip()[:40] + + +def source_ordinals(md_path): + """Map normalized item text -> set of source ordinals (outside fenced code).""" + out = {} + fence = None + with open(md_path, encoding='utf-8') as fh: + for raw in fh: + line = raw.rstrip('\n') + if fence is not None: + s = line.strip() + if s and set(s) == {fence[0]} and len(s) >= fence[1]: + fence = None + continue + fm = FENCE.match(line) + if fm: + fence = (fm.group(1)[0], len(fm.group(1))) + continue + m = ORDERED.match(line) + if m: + key = normalize(m.group(2)) + if key: + out.setdefault(key, set()).add(int(m.group(1))) + return out + + +class Tree(HTMLParser): + """Tolerant HTML-to-tree parser (only the structure we need).""" + + def __init__(self): + super().__init__() + self.root = {'tag': 'root', 'children': [], 'text': ''} + self.stack = [self.root] + + def handle_starttag(self, tag, attrs): + node = {'tag': tag, 'attrs': dict(attrs), 'children': [], 'text': ''} + self.stack[-1]['children'].append(node) + if tag not in VOID: + self.stack.append(node) + + def handle_startendtag(self, tag, attrs): + self.stack[-1]['children'].append({'tag': tag, 'attrs': dict(attrs), 'children': [], 'text': ''}) + + def handle_endtag(self, tag): + for i in range(len(self.stack) - 1, 0, -1): + if self.stack[i]['tag'] == tag: + del self.stack[i:] + return + + def handle_data(self, data): + if data.strip(): + self.stack[-1]['text'] += ' ' + data.strip() + + +def find_content(node): + """Return the page's main content node, ignoring theme nav/TOC lists.""" + if node['tag'] in ('article', 'main'): + return node + for child in node['children']: + found = find_content(child) + if found: + return found + return None + + +def _text_before_nested_list(node): + # Include the node's own direct text (a tight-list
  1. holds its text + # directly; a loose-list
  2. wraps it in a child

    ), then descend into + # children until the item's nested list begins. + parts = [node['text']] if node['text'] else [] + for child in node['children']: + if child['tag'] in ('ol', 'ul'): + break + parts.append(_text_before_nested_list(child)) + return ' '.join(p for p in parts if p) + + +def first_item_text(ol): + for child in ol['children']: + if child['tag'] == 'li': + return normalize(_text_before_nested_list(child)) + return '' + + +def sibling_ol_runs(node): + """Yield lists of >=2

      siblings not separated by a heading.""" + run = [] + for child in node['children']: + if child['tag'] == 'ol': + run.append(child) + elif child['tag'] in HEADINGS: + if len(run) >= 2: + yield run + run = [] + if len(run) >= 2: + yield run + for child in node['children']: + yield from sibling_ol_runs(child) + + +def find_breaks(md_path, html_path): + """Return [(source_ordinal, item_text), ...] for confirmed numbering breaks.""" + ordinals = source_ordinals(md_path) + tree = Tree() + with open(html_path, encoding='utf-8') as fh: + tree.feed(fh.read()) + content = find_content(tree.root) or tree.root + breaks = [] + for run in sibling_ol_runs(content): + for ol in run[1:]: # the first fragment may legitimately start at 1 + text = first_item_text(ol) + if not text: + continue + nums = ordinals.get(text) + if nums and min(nums) > 1: # authored >1 everywhere -> must not be a list start + breaks.append((min(nums), text)) + return breaks + + +def html_for(md_relpath, site): + stem = md_relpath[:-3] if md_relpath.endswith('.md') else md_relpath + page_dir = os.path.dirname(stem) if os.path.basename(stem) == 'index' else stem + return os.path.join(site, page_dir, 'index.html') + + +def collect(docs, site): + findings = {} + for md in sorted(glob.glob(os.path.join(docs, '**', '*.md'), recursive=True)): + rel = os.path.relpath(md, docs) + html = html_for(rel, site) + if not os.path.exists(html): + continue + breaks = find_breaks(md, html) + if breaks: + findings[rel] = breaks + return findings + + +def as_baseline_keys(findings): + return sorted({f"{page}::{num}::{text}" for page, breaks in findings.items() for num, text in breaks}) + + +def main(argv): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('docs', nargs='?', default='docs') + parser.add_argument('site', nargs='?', default='site') + parser.add_argument('--baseline') + parser.add_argument('--update-baseline') + args = parser.parse_args(argv[1:]) + + if not os.path.isdir(args.site): + print(f"error: rendered site not found at '{args.site}'. Run `mkdocs build` first.", file=sys.stderr) + return 2 + + findings = collect(args.docs, args.site) + + if args.update_baseline: + with open(args.update_baseline, 'w', encoding='utf-8') as fh: + json.dump(as_baseline_keys(findings), fh, indent=2) + fh.write('\n') + print(f"wrote baseline with {len(as_baseline_keys(findings))} entr(y/ies) to {args.update_baseline}") + return 0 + + baseline = set() + if args.baseline and os.path.exists(args.baseline): + with open(args.baseline, encoding='utf-8') as fh: + baseline = set(json.load(fh)) + + new_count = 0 + for page in sorted(findings): + rows = [(num, text) for num, text in findings[page] if f"{page}::{num}::{text}" not in baseline] + if not rows: + continue + new_count += len(rows) + print(f"\n{page}") + for num, text in rows: + print(f" numbering break: author wrote #{num} but it renders as a restarted '1.' -> {text!r}") + + baselined = sum(len(b) for b in findings.values()) - new_count + print(f"\n=== {new_count} new numbering break(s); {baselined} baselined; {len(findings)} affected page(s) ===") + if new_count: + print("Fix by indenting the item's continuation content to 4 spaces so the list " + "stays intact, or (if intentional) restart the source numbering at 1.", file=sys.stderr) + return 1 if new_count else 0 + + +if __name__ == '__main__': + sys.exit(main(sys.argv)) diff --git a/scripts/list-numbering-baseline.json b/scripts/list-numbering-baseline.json new file mode 100644 index 000000000..90b6fc3e5 --- /dev/null +++ b/scripts/list-numbering-baseline.json @@ -0,0 +1,7 @@ +[ + "2-sensors-deployment/adapters/types/sophos.md::5::now you have all the pieces for your ada", + "2-sensors-deployment/endpoint-agent/windows/installation.md::3::run the script with your installation ke", + "5-integrations/extensions/third-party/plaso.md::2::launch an mft dump in the limacharlie du", + "5-integrations/tutorials/hayabusa-bigquery.md::2::specific event types hayabusaevent", + "5-integrations/tutorials/velociraptor-bigquery.md::2::specific event types velociraptorcollect" +] diff --git a/scripts/publish-release-note.py b/scripts/publish-release-note.py index ebd0a755d..ab01a92ea 100644 --- a/scripts/publish-release-note.py +++ b/scripts/publish-release-note.py @@ -16,15 +16,45 @@ """ import argparse +import html import os import re import sys from datetime import datetime +from urllib.parse import urlparse DOCS_DIR = os.path.join(os.path.dirname(__file__), "..", "docs", "10-release-notes") MKDOCS_YML = os.path.join(os.path.dirname(__file__), "..", "mkdocs.yml") +# Allowlisted hosts for the release URL. A URL is accepted only when its host is +# one of these exactly or a subdomain of one (e.g. docs.limacharlie.io). The +# dotted-boundary check ("." + domain) prevents look-alike hosts such as +# "evilgithub.com" or "limacharlie.io.attacker.example" from slipping through an +# endswith() match. +ALLOWED_URL_HOSTS = ("github.com", "limacharlie.io") + +# Upper bound on the release-note body. Release notes are short; this simply +# caps how much untrusted, machine-fed content we will ever write into the docs. +MAX_BODY_LEN = 50000 + +# URL schemes that may appear in a Markdown link or image destination in the +# release URL or body. Anything else (javascript:, data:, vbscript:, file:, ...) +# is rejected: those render to a live href/src and would be a stored one-click +# XSS on the docs site. Scheme-less destinations (relative paths, "#anchors", +# "example.com/x") have no scheme and are always allowed. +ALLOWED_LINK_SCHEMES = ("http", "https", "mailto") + +# Matches a leading URL scheme ("javascript:", "https:", ...) on a destination. +_SCHEME_RE = re.compile(r"^([a-zA-Z][a-zA-Z0-9+.\-]*):") + +# Inline link/image destination: the text right after "](", up to the first +# whitespace, ">", or ")". Handles an optional leading "<". +_INLINE_DEST_RE = re.compile(r"\]\(\s*]+)") + +# Reference-style link definition at the start of a line: "[label]: dest". +_REF_DEST_RE = re.compile(r"(?m)^[ \t]{0,3}\[[^\]]+\]:\s*]+)") + def parse_date(date_str: str) -> datetime: """Parse ISO 8601 or YYYY-MM-DD date string.""" @@ -44,7 +74,7 @@ def ensure_monthly_file(dt: datetime) -> str: if not os.path.exists(filepath): month_label = dt.strftime("%B %Y") with open(filepath, "w") as f: - f.write(f"# Release Notes — {month_label}\n") + f.write(f"# Release Notes - {month_label}\n") return filepath @@ -129,15 +159,95 @@ def update_mkdocs_nav(dt: datetime) -> None: def validate_inputs(component: str, version: str) -> None: - """Validate component and version to prevent path traversal or injection.""" - if not re.match(r'^[\w][\w.-]*$', component): + """Validate component and version to prevent path traversal or injection. + + Uses ``re.fullmatch`` so the whole string must match: ``re.match`` with a + trailing ``$`` would also accept a single trailing newline, letting a caller + smuggle a line break into the generated heading. + """ + if not re.fullmatch(r'[\w][\w.-]*', component): print(f"Invalid component name: {component}", file=sys.stderr) sys.exit(1) - if not re.match(r'^v?[\d]+[\d.]*[\w.-]*$', version): + if not re.fullmatch(r'v?[\d]+[\d.]*[\w.-]*', version): print(f"Invalid version: {version}", file=sys.stderr) sys.exit(1) +def validate_url(url: str) -> None: + """Validate the optional release URL before it is embedded in the docs. + + An empty URL is allowed (the field is optional). A non-empty URL must use the + https scheme and point at an allowlisted host (see ALLOWED_URL_HOSTS); + anything else is rejected so a caller cannot inject a link to an arbitrary + (e.g. javascript:, http:, or attacker-controlled) destination. + """ + if not url: + return + parsed = urlparse(url) + if parsed.scheme != "https": + print(f"Invalid URL scheme (must be https): {url}", file=sys.stderr) + sys.exit(1) + host = (parsed.hostname or "").lower() + if not any(host == d or host.endswith("." + d) for d in ALLOWED_URL_HOSTS): + print(f"URL host not in allowlist ({', '.join(ALLOWED_URL_HOSTS)}): {url}", file=sys.stderr) + sys.exit(1) + # urlparse().hostname stops at the first "/", "?" or "#", so the host + # allowlist alone does not vet the rest of the string - and the raw URL is + # embedded verbatim inside a Markdown link destination ("[GitHub Release](URL)"). + # A ")" or whitespace would close/break that destination and let a caller + # inject further Markdown (e.g. a javascript: link), which passes the host + # check. None of these characters appear in a legitimate release URL, so + # reject them. + forbidden = set(' \t\r\n"`\\()<>') + if any(c in forbidden or ord(c) < 0x20 for c in url): + print(f"URL contains forbidden characters: {url}", file=sys.stderr) + sys.exit(1) + + +def reject_dangerous_link_schemes(body: str) -> None: + """Reject the publish if a Markdown link/image destination uses a bad scheme. + + ``html.escape`` neutralizes raw HTML and autolinks, but Markdown inline links + (``[x](javascript:...)``) and reference definitions (``[x]: javascript:...``) + still render to a live ```` regardless of HTML escaping, so a + ``javascript:`` or ``data:`` destination would be a stored one-click XSS on + the docs site. A legitimate, machine-generated release note never uses those + schemes, so we fail closed rather than trying to rewrite the body. + """ + for pattern in (_INLINE_DEST_RE, _REF_DEST_RE): + for match in pattern.finditer(body): + dest = match.group(1) + scheme = _SCHEME_RE.match(dest) + if scheme and scheme.group(1).lower() not in ALLOWED_LINK_SCHEMES: + print(f"Disallowed URL scheme in release body link: {dest}", file=sys.stderr) + sys.exit(1) + + +def sanitize_body(body: str) -> str: + """Bound and neutralize the release-note body before it is written to docs. + + The body is machine-fed via repository_dispatch and rendered by MkDocs with + md_in_html enabled. Two classes of active content must be defused: + + 1. Raw HTML (e.g.