diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6c1a55a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,70 @@ +# Version control +.git +.gitignore + +# Python bytecode +__pycache__/ +*.py[cod] +*.pyo +*.pyd +*.so + +# Build artefacts +*.egg-info/ +*.egg +dist/ +build/ +wheels/ +sdist/ + +# Virtual environments +.venv/ +venv/ +env/ +ENV/ + +# Test infrastructure (not needed in the image) +tests/ +.pytest_cache/ +.coverage +.tox/ +nosetests.xml + +# Type-checking caches +.mypy_cache/ +.ruff_cache/ + +# IDE / editor artefacts +.idea/ +.vscode/ +*.swp +*.swo + +# Secrets (never bake into the image) +.env +.env.local +*.pem +*.key + +# CI/CD configuration +.github/ +.travis.yml + +# Legacy packaging that is not needed for Docker builds +hacklog.spec +scripts/ + +# Documentation and data samples (reduce image context size) +doc/ +data/ + +# Database files +*.db +*.sqlite +*.sqlite3 +hacklog/hacklog.db + +# Miscellaneous +*.log +*.pid +CHANGES diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e096b70 --- /dev/null +++ b/.env.example @@ -0,0 +1,42 @@ +# Hacklog environment configuration +# Copy to .env and fill in required secrets before starting the application. + +# --- Required SMTP secrets --- +HACKLOG_SMTP_USER= +HACKLOG_SMTP_PASSWORD= +HACKLOG_SMTP_SENDER= +HACKLOG_SMTP_HOST=smtp.gmail.com +HACKLOG_SMTP_PORT=587 +HACKLOG_ALERT_RECIPIENT= +# HACKLOG_ALLOWED_CIDRS=10.0.0.0/8,192.168.0.0/16 + +# --- Optional metrics overrides --- +# HACKLOG_METRICS_ENABLED=false +# HACKLOG_METRICS_PORT=9090 + +# --- Optional syslog listener overrides --- +# HACKLOG_SYSLOG_BIND_ADDRESS=127.0.0.1 +# HACKLOG_SYSLOG_PORT=10514 +# HACKLOG_SYSLOG_MAX_MESSAGE_SIZE=8192 +# HACKLOG_SYSLOG_RATE_LIMIT_PER_SOURCE=100 + +# --- Optional database overrides --- +# HACKLOG_DATABASE_DB_URL=sqlite:///hacklog.db +# HACKLOG_DATABASE_POOL_SIZE=5 + +# --- Optional security overrides --- +# HACKLOG_SECURITY_ALLOWED_SOURCE_CIDRS=10.0.0.0/8,192.168.0.0/16 + +# --- Optional scoring overrides (defaults match legacy algorithm.py constants) --- +# HACKLOG_SCORING_HOURS_WEIGHT=10 +# HACKLOG_SCORING_DAYS_WEIGHT=10 +# HACKLOG_SCORING_SERVER_WEIGHT=15 +# HACKLOG_SCORING_SUCCESS_WEIGHT=35 +# HACKLOG_SCORING_VPN_WEIGHT=0 +# HACKLOG_SCORING_INTERNAL_WEIGHT=10 +# HACKLOG_SCORING_EXTERNAL_WEIGHT=15 +# HACKLOG_SCORING_IP_WEIGHT=15 +# HACKLOG_SCORING_CRITICAL_THRESHOLD=50 +# HACKLOG_SCORING_SCARY_THRESHOLD=30 +# HACKLOG_SCORING_SCARE_COUNT_LIMIT=2 +# HACKLOG_SCORING_SCARE_DATE_EXPIRE_DAYS=1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bce2315 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,55 @@ +name: CI + +on: + push: + branches: [main, master, release-next] + pull_request: + branches: [main, master, release-next] + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + quality: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.12", "3.13"] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: pyproject.toml + + - name: Install dependencies + run: "pip install --only-binary=:all: -r requirements-ci.txt" + + - name: Ruff + run: ruff check hacklog/ tests/ + + - name: Black + run: black --check hacklog/ tests/ + + - name: isort + run: isort --check hacklog/ tests/ + + - name: Mypy (typed modules) + run: >- + mypy hacklog/validators.py hacklog/security.py hacklog/metrics.py + --disallow-untyped-defs --ignore-missing-imports --follow-imports=skip + --disable-error-code=no-redef --disable-error-code=no-any-return + + - name: Bandit + run: bandit -ll -ii -r hacklog/ + + - name: Pytest + env: + PYTHONPATH: ${{ github.workspace }}:${{ github.workspace }}/hacklog + run: pytest tests/ --cov=hacklog --cov-report=xml diff --git a/.gitignore b/.gitignore index 61db1ff..cd07459 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,15 @@ nosetests.xml .mr.developer.cfg .project .pydevproject +.forge-commit-ready +.forge/project-id +.forge/hook-version +.cursor/hooks.json +.cursor/hooks/ +.cursor/rules/forge-workflow.mdc +.claude/ +CLAUDE.md + +# Local dev server artifacts +.hacklog-dev.pid +var/log/ diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 45c798b..0000000 --- a/.travis.yml +++ /dev/null @@ -1,9 +0,0 @@ -language: python -python: - - "2.7" - - "2.6" -# command to install dependencies, e.g. pip install -r requirements.txt --use-mirrors -install: python setup.py install -# # command to run tests, e.g. python setup.py test -script: python setup.py test - diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c632fd6 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,280 @@ +# Contributing to Hacklog + +Welcome! This guide will get you from a fresh clone to a working development environment with passing tests in under 30 minutes. + +--- + +## Table of Contents + +1. [Prerequisites](#prerequisites) +2. [Development Environment Setup](#development-environment-setup) +3. [Running Tests](#running-tests) +4. [Code Style](#code-style) +5. [Architecture Overview](#architecture-overview) +6. [PR Process](#pr-process) + +--- + +## Prerequisites + +| Tool | Minimum Version | Notes | +|------|----------------|-------| +| Python | 3.12 | 3.13 also supported and tested in CI | +| Git | any recent | — | +| Docker | 24+ | Optional — only needed for container-based testing | + +Check your Python version: + +```bash +python --version # must be 3.12.x or 3.13.x +``` + +--- + +## Development Environment Setup + +### 1. Clone the repository + +```bash +git clone https://github.com/dandb/hacklog.git +cd hacklog +``` + +### 2. Create and activate a virtual environment + +```bash +python -m venv .venv +source .venv/bin/activate # Linux / macOS +# .venv\Scripts\activate # Windows +``` + +### 3. Install the package with test and dev dependencies + +```bash +pip install -e '.[test,dev]' +``` + +This installs: +- **Runtime dependencies** — `sqlalchemy`, `aiosmtplib`, `pydantic-settings`, `structlog`, `pyyaml`, `prometheus-client`, `alembic` +- **Test dependencies** — `pytest`, `pytest-asyncio`, `pytest-cov`, `hypothesis`, `coverage`, `bandit` +- **Dev dependencies** — `ruff`, `black`, `isort`, `mypy`, `types-PyYAML` + +### 4. Verify the installation + +```bash +pytest tests/ -q +``` + +All tests should pass. You are ready to develop. + +### 5. Run the server locally + +Hacklog reads **SMTP secrets and tuning from `HACKLOG_*` environment variables** via `ConfigManager` (`hacklog/config.py`). Legacy bind/port and parser patterns still come from `conf/server.conf`. + +```bash +cp .env.example .env # fill in HACKLOG_SMTP_* and HACKLOG_ALERT_RECIPIENT +make dev-start # or: ./scripts/run.sh +make dev-status # check pid file +make dev-stop # SIGTERM graceful shutdown (no kill -9) +``` + +Logs are written to `var/log/hacklog-dev.log`. The pid file defaults to `.hacklog-dev.pid` in the repo root. + +**Docker alternative:** `docker compose up -d` (see README) — same `HACKLOG_*` variables, container-managed lifecycle. + +**Production:** use `deploy/hacklog.service` (systemd) — see README *Quick Start — Bare Metal*. + +#### Legacy `hacklog/run.sh` and `hacklog/stop.sh` (removed) + +Older clones included crude helpers under `hacklog/run.sh` and `hacklog/stop.sh` that invoked `python server.py` directly and stopped the process with `ps | grep | kill -9`. Those scripts are **removed** in favor of: + +| Use case | Replacement | +|----------|-------------| +| Local development | `make dev-start` / `make dev-stop` (`scripts/run.sh`, `scripts/stop.sh`) | +| Container deployment | `docker compose up` / `docker compose down` | +| Bare-metal production | `systemctl start hacklog` / `systemctl stop hacklog` (`deploy/hacklog.service`) | + +The modern dev scripts use correct `#!/bin/sh` shebangs, load `HACKLOG_*` via ConfigManager, and stop with **SIGTERM** (graceful shutdown) using a pid file — not `kill -9`. + +--- + +## Running Tests + +### Full test suite + +```bash +pytest tests/ +``` + +### With coverage report + +```bash +pytest tests/ --cov=hacklog --cov-report=term-missing +``` + +### Single test file + +```bash +pytest tests/test_scoring_engine.py -v +``` + +### Single test by name + +```bash +pytest tests/test_retention.py::test_run_purge_full_pipeline -v +``` + +### Async tests + +The test suite uses `pytest-asyncio` with `asyncio_mode = "auto"` (configured in `pyproject.toml`), so async tests run automatically without any extra flags. + +### Security scan + +```bash +bandit -r hacklog/ +``` + +--- + +## Code Style + +Hacklog enforces style with three tools, all configured in `pyproject.toml`: + +| Tool | Purpose | Command | +|------|---------|---------| +| `black` | Opinionated code formatter | `black hacklog/ tests/` | +| `isort` | Import ordering | `isort hacklog/ tests/` | +| `ruff` | Fast linting (E, F, I, N, W rules) | `ruff check hacklog/ tests/` | + +Run all three at once before committing: + +```bash +black hacklog/ tests/ +isort hacklog/ tests/ +ruff check hacklog/ tests/ +``` + +### Type checking + +```bash +mypy hacklog/ +``` + +### CI enforcement + +The GitHub Actions CI pipeline runs ruff, black, isort, mypy, bandit, and pytest against Python 3.12 and 3.13 on every push and pull request. A PR cannot be merged unless all checks pass. + +--- + +## Architecture Overview + +Hacklog is structured in six layers. Understanding this helps you locate the right file for a given change. + +``` +┌─────────────────────────────────────────────────────┐ +│ 1. Syslog Ingestion (hacklog/syslog_server.py) │ +│ UDP listener → validates source CIDR → │ +│ rate-limits per source IP │ +├─────────────────────────────────────────────────────┤ +│ 2. Parsing (hacklog/parse.py) │ +│ Extracts username, IP, server, success/fail │ +│ from sshd log lines → EventLog entity │ +├─────────────────────────────────────────────────────┤ +│ 3. Scoring Engine (hacklog/scoring.py) │ +│ Weighted surprisal across 6 dimensions → │ +│ compares event to user's profile baseline │ +├─────────────────────────────────────────────────────┤ +│ 4. Alerting (hacklog/alerting.py) │ +│ Async SMTP delivery with circuit breaker, │ +│ retry, and dead-letter queue │ +├─────────────────────────────────────────────────────┤ +│ 5. Persistence (hacklog/repositories.py) │ +│ SQLAlchemy ORM → SQLite; repository pattern; │ +│ Alembic migrations; append-only audit trail │ +├─────────────────────────────────────────────────────┤ +│ 6. Config & Observability │ +│ pydantic-settings env vars; structlog JSON; │ +│ Prometheus metrics; data retention / purge │ +└─────────────────────────────────────────────────────┘ +``` + +### Key files + +| File | What to change here | +|------|-------------------| +| `hacklog/entities.py` | SQLAlchemy models, `Weight`/`Threshold` constants | +| `hacklog/repositories.py` | Data access — add query or persistence methods | +| `hacklog/services.py` | Profile update logic | +| `hacklog/scoring.py` | Risk scoring algorithm | +| `hacklog/alerting.py` | Alert delivery, circuit breaker behaviour | +| `hacklog/retention.py` | Data retention / purge logic | +| `hacklog/config.py` | New configuration fields | +| `hacklog/logging_config.py` | Structured logging processors | +| `migrations/versions/` | Alembic schema migrations | +| `tests/` | Tests — one file per module, same name prefix | + +### Dependency injection + +Services are wired together via constructor injection. `ScoringEngine` accepts `UpdateService`, `AlertService`, and an optional `AuditRepository`. This makes all components independently testable with mocks — you will rarely need an actual database in unit tests. + +### Database migrations + +When you add or modify a SQLAlchemy model in `entities.py`, create a migration: + +```bash +alembic revision -m "describe_your_change" +# edit the generated file in migrations/versions/ +alembic upgrade head +``` + +The existing migrations in `migrations/versions/` are numbered `001`, `002`, `003` — follow the same convention. + +--- + +## PR Process + +1. **Fork** the repository and create a feature branch from `master`: + + ```bash + git checkout -b feature/my-change + ``` + +2. **Write tests first** (or alongside the change). Every new behaviour must have a test. Every bug fix must have a regression test. + +3. **Run the full check suite locally** before pushing: + + ```bash + black hacklog/ tests/ + isort hacklog/ tests/ + ruff check hacklog/ tests/ + mypy hacklog/ + pytest tests/ --cov=hacklog + bandit -r hacklog/ + ``` + +4. **Open a pull request** against `master`. Fill in the PR description with: + - What changed and why + - How to test it manually (if applicable) + - Any migration steps required + +5. **CI must be green** — all checks on Python 3.12 and 3.13 must pass before review. + +6. **One approving review** from a maintainer is required before merge. + +7. **Squash or rebase** to keep a clean linear history. + +### Commit message style + +``` +[WO-NNN] Short imperative summary (≤72 chars) + +Optional longer explanation of why the change was made, +not what was changed (the diff shows that). +``` + +### What not to include in a PR + +- Credentials, secrets, or `.env` files +- Compiled binaries or generated files +- Changes to `CLAUDE.md` or `.claude/` directories +- Unrelated refactoring mixed with a feature or bug fix diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1bc6986 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,76 @@ +# ─── Stage 1: builder ──────────────────────────────────────────────────────── +# Install runtime dependencies and build the hacklog wheel. +FROM python:3.12-slim AS builder + +# Avoid .pyc bytecode in the build layer and unbuffer output +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /build + +# Copy locked runtime deps first for layer caching (Sonar S8544). +COPY requirements-runtime.txt ./ + +RUN pip install --no-cache-dir --only-binary=:all: --require-hashes -r requirements-runtime.txt + +# Copy application source. Changing only source invalidates this layer onward +# but preserves the dependency layer above. +COPY hacklog/ ./hacklog/ + + +# ─── Stage 2: runtime ───────────────────────────────────────────────────────── +# Minimal image containing only the installed package and application source. +FROM python:3.12-slim AS runtime + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +# Create a dedicated non-root system user (UID 1000) for security. +RUN useradd -r -u 1000 -m -s /sbin/nologin hacklog + +WORKDIR /app + +# Copy installed Python packages from the builder stage. +COPY --from=builder /usr/local/lib/python3.12/site-packages \ + /usr/local/lib/python3.12/site-packages + +# Copy application source so the server can be launched as a script. +# Running `python hacklog/server.py` adds hacklog/ to sys.path[0], which +# satisfies the bare imports (e.g. `from alerting import AlertService`) used +# throughout the package without requiring a PYTHONPATH override. +COPY --from=builder /build/hacklog ./hacklog/ + +# Copy the health check script used by the HEALTHCHECK instruction. +COPY healthcheck.py /usr/local/bin/healthcheck.py + +# Create volume mount points and set ownership before dropping to non-root. +RUN mkdir -p /data /var/log/hacklog \ + && chown -R hacklog:hacklog /data /var/log/hacklog /app + +# ── Default environment variables ──────────────────────────────────────────── +# Bind to all interfaces so the UDP port is reachable from the Docker host. +ENV HACKLOG_SYSLOG_BIND_ADDRESS=0.0.0.0 +# Use the mounted /data volume for the SQLite database. +ENV HACKLOG_DATABASE_DB_URL=sqlite:////data/hacklog.db + +# ── Port and volumes ───────────────────────────────────────────────────────── +# Expose the syslog UDP listener port. +EXPOSE 10514/udp + +# /data → SQLite database file (hacklog.db) +# /var/log/hacklog → dead-letter JSON-lines files written on DB failure +VOLUME ["/data", "/var/log/hacklog"] + +# Drop privileges to the non-root hacklog user. +USER hacklog + +# ── Health check ───────────────────────────────────────────────────────────── +# Verifies the application is running by confirming that UDP port 10514 is +# already bound (i.e. the syslog listener is active). Exit 0 = healthy. +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD python /usr/local/bin/healthcheck.py + +# ── Entrypoint ─────────────────────────────────────────────────────────────── +# Run the syslog server. Required secrets (HACKLOG_SMTP_USER, etc.) must be +# supplied at container launch via -e / --env-file / docker-compose env section. +CMD ["python", "hacklog/server.py"] diff --git a/FORGE.md b/FORGE.md new file mode 100644 index 0000000..5a8fc4d --- /dev/null +++ b/FORGE.md @@ -0,0 +1,30 @@ +# Forge Implementation Log + +| Field | Value | +|-------|-------| +| Project | f2503b74-7a41-41ea-bd08-639ede6aa08f | +| Branch | forge/hacklog-0e55b11c-run2-create-dockerfile-for-containe | +| Started | 2026-08-07T14:28:15Z | + +--- + +## WO-023: User Story: WO-023 - Create Dockerfile for containerized deployment +- **Status:** completed +- **Commit:** `1462ffd` +- **Files:** 1 (+2/-0) +- **Duration:** 734ss +- **Approach:** Multi-stage Dockerfile: builder stage copies pyproject.toml, README.md, LICENSE, and hacklog/ source then runs pip install using hatchling; runtime stage copies only site-packages and application source. Server launched as a script (python hacklog/server.py) so Python adds hacklog/ to sys.path[0], satisfying the existing bare imports without PYTHONPATH manipulation. Non-root hacklog user UID 1000. UDP port 10514 exposed. Volumes for /data and /var/log/hacklog. HEALTHCHECK via healthcheck.py which tries to bind the UDP port — if it fails (EADDRINUSE) the server is running (healthy). docker-compose.yml provides full dev/test environment with optional Prometheus profile. README updated with Docker quickstart, env-var table, and image details. + +## WO-025: User Story: WO-025 - Implement audit logging for scoring and alert events +- **Status:** completed +- **Commit:** `5a80323` +- **Files:** 7 (+651/-12) +- **Duration:** 549ss +- **Approach:** Added AuditRecord SQLAlchemy entity with id/timestamp/actor/source_ip/resource/action/outcome/details fields. Extended AuditRepository with append-only save_audit_record method. Integrated audit record emission into ScoringEngine (via _emit_audit_record helper) after every process_event_log call covering score_calculated, scare_count_updated, and scare_count_reset actions. Integrated into AlertService.send_alert for alert_sent and alert_suppressed actions. Both services emit structured log entries with audit=True tag and optionally persist to DB when audit_repository is injected. Modified calculate_new_score to return (total_score, dimension_scores) tuple so dimension scores are captured in audit records. Updated existing test that mocked calculate_new_score to return the tuple. Created Alembic migration 003_create_audit_table.py with timestamp index for retention queries. + +## WO-026: User Story: WO-026 - Implement data retention and automated purge +- **Status:** completed +- **Commit:** `74cf0fc` +- **Files:** 3 (+773/-0) +- **Duration:** 502ss +- **Approach:** Added RetentionConfig Pydantic model to config.py with event_retention_days (default 365), profile_inactivity_days (default 180), purge_schedule_hour (default 2), and purge_batch_size (default 1000) — loaded from HACKLOG_EVENT_RETENTION_DAYS and HACKLOG_PROFILE_INACTIVITY_DAYS env vars via _RetentionSettings. Wired into ConfigManager.retention and load_config. Created hacklog/retention.py with DataRetentionService: purge_event_logs() uses batched SELECT LIMIT + DELETE IN to physically delete old EventLog records; purge_inactive_profiles() finds users whose max activity date across all tables (EventLog, Days, Hours, Server, IpAddress) falls before the inactivity cutoff, then physically deletes all their records; run_purge() orchestrates both; schedule_daily_purge() is an async scheduler that sleeps until the configured UTC hour daily and invokes run_purge via asyncio.to_thread. Both purge operations emit structlog entries with audit=True and optionally persist AuditRecord via the injected AuditRepository from WO-025. Created 17 tests covering boundary conditions, batch processing, idempotency, audit record creation, config defaults/env overrides, full pipeline integration, and async scheduler smoke test. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e12b6cc --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +.PHONY: dev-start dev-stop dev-status dev-restart test lint + +dev-start: + ./scripts/run.sh + +dev-stop: + ./scripts/stop.sh + +dev-status: + ./scripts/dev-status.sh + +dev-restart: dev-stop dev-start + +test: + pytest tests/ -q + +lint: + ruff check hacklog tests + ruff format --check hacklog tests diff --git a/README.md b/README.md index bf5f0f4..612b3f4 100644 --- a/README.md +++ b/README.md @@ -1,56 +1,320 @@ -================== -What is Hacklog? -================== +# Hacklog -Hacklog is a security software that detects compromised user accounts -by applying statistical analysis to service access logs. +[![CI](https://github.com/alekhyaakkiraju-droid/hacklog/actions/workflows/ci.yml/badge.svg)](https://github.com/alekhyaakkiraju-droid/hacklog/actions/workflows/ci.yml) -Hacklog is implemented as a system deamon that accepts log stream via syslog -protocol. +Hacklog is a security daemon that detects compromised user accounts by applying statistical analysis to SSH authentication logs. It listens for syslog messages over UDP, scores each authentication event using a weighted surprisal model, and sends email alerts when a user's behaviour deviates significantly from their historical baseline. +--- -http://dandb.github.io/hacklog/ +## Table of Contents -Development -============ +1. [Architecture Overview](#architecture-overview) +2. [Quick Start — Docker](#quick-start--docker) +3. [Quick Start — Bare Metal](#quick-start--bare-metal) +4. [Configuration Reference](#configuration-reference) +5. [Scoring Algorithm](#scoring-algorithm) +6. [Development](#development) +7. [License](#license) -[![Build Status](https://travis-ci.org/dandb/hacklog.svg)](https://travis-ci.org/dandb/hacklog) +--- -Clone repository and install the project +## Architecture Overview + +Hacklog is structured in six layers: + +``` +┌─────────────────────────────────────────────────────┐ +│ 1. Syslog Ingestion (hacklog/syslog_server.py) │ +│ UDP listener → validates source CIDR → │ +│ rate-limits per source IP │ +├─────────────────────────────────────────────────────┤ +│ 2. Parsing (hacklog/parse.py) │ +│ Extracts username, IP, server, success/fail │ +│ from sshd log lines → EventLog entity │ +├─────────────────────────────────────────────────────┤ +│ 3. Scoring Engine (hacklog/scoring.py) │ +│ Weighted surprisal across 6 dimensions → │ +│ compares event to user's profile baseline │ +├─────────────────────────────────────────────────────┤ +│ 4. Alerting (hacklog/alerting.py) │ +│ Async SMTP delivery with circuit breaker, │ +│ retry, and dead-letter queue │ +├─────────────────────────────────────────────────────┤ +│ 5. Persistence (hacklog/repositories.py) │ +│ SQLAlchemy ORM → SQLite; repository pattern; │ +│ Alembic migrations; append-only audit trail │ +├─────────────────────────────────────────────────────┤ +│ 6. Config & Observability │ +│ pydantic-settings env vars; structlog JSON; │ +│ Prometheus metrics; data retention / purge │ +└─────────────────────────────────────────────────────┘ ``` -git clone git@github.com:dandb/hacklog.git + +**Key components:** + +| Module | Responsibility | +|--------|----------------| +| `syslog_server.py` | Async UDP syslog receiver with CIDR filtering and rate limiting | +| `parse.py` | Converts raw syslog lines into `EventLog` entities | +| `scoring.py` | `ScoringEngine` — computes risk scores and triggers alerts | +| `alerting.py` | `AlertService` — async SMTP with circuit breaker and dead-letter queue | +| `repositories.py` | `UserRepository`, `ProfileRepository`, `AuditRepository` | +| `services.py` | `UpdateService` — profile frequency tracking | +| `retention.py` | `DataRetentionService` — configurable purge with asyncio scheduling | +| `config.py` | `ConfigManager` — pydantic-settings with YAML and env-var support | + +--- + +## Quick Start — Docker + +### Prerequisites + +- Docker 24+ and Docker Compose v2 + +### 1. Clone and configure + +```bash +git clone https://github.com/dandb/hacklog.git cd hacklog -python setup.py install -python setup.py test +cp .env.example .env +$EDITOR .env # set HACKLOG_SMTP_USER, HACKLOG_SMTP_PASSWORD, HACKLOG_SMTP_SENDER, + # HACKLOG_ALERT_RECIPIENT (required) +``` + +### 2. Build and start + +```bash +# Build the image +docker build -t hacklog:latest . + +# Start the container +docker run -d \ + --name hacklog \ + --env-file .env \ + -e HACKLOG_SYSLOG_BIND_ADDRESS=0.0.0.0 \ + -e HACKLOG_DATABASE_DB_URL=sqlite:////data/hacklog.db \ + -p 10514:10514/udp \ + -v hacklog_data:/data \ + -v hacklog_logs:/var/log/hacklog \ + hacklog:latest +``` + +### 3. Verify + +```bash +docker ps # STATUS should be "healthy" +docker logs hacklog # inspect startup output +``` + +### 4. Send a test event + +```bash +echo "<14>sshd[1234]: Accepted publickey for alice from 10.0.0.1 port 22 ssh2" \ + | nc -u -w1 127.0.0.1 10514 ``` -Start software +### Docker Compose (recommended for dev/test) + +```bash +cp .env.example .env && $EDITOR .env +docker compose up -d +docker compose ps # confirm healthy + +# Optional: start with Prometheus monitoring +docker compose --profile monitoring up -d +# Prometheus UI → http://localhost:9091 ``` -cd hacklog/hacklog -./start.sh # start service -./stop.sh # stop service + +--- + +## Quick Start — Bare Metal + +### Prerequisites + +- Python 3.12 or 3.13 +- systemd 245+ (for service management) + +### Install + +```bash +git clone https://github.com/dandb/hacklog.git +cd hacklog +python -m venv .venv +source .venv/bin/activate +pip install . ``` -Deployment -========== +### Configure and run + +```bash +# Copy the example env file and fill in secrets +cp deploy/hacklog.env.example /etc/hacklog/hacklog.env +$EDITOR /etc/hacklog/hacklog.env + +# Install and enable the systemd service +sudo cp deploy/hacklog.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now hacklog + +# Check status +sudo systemctl status hacklog +journalctl -u hacklog -f +``` + +--- + +## Configuration Reference + +All configuration is supplied via environment variables. Values from `.env` / `--env-file` are loaded at startup. Environment variables always take precedence over YAML config file values. + +### SMTP (required) -Install hacklog package -``yum -y install hacklog`` +| Variable | Default | Description | +|----------|---------|-------------| +| `HACKLOG_SMTP_HOST` | `smtp.gmail.com` | SMTP server hostname | +| `HACKLOG_SMTP_PORT` | `587` | SMTP server port | +| `HACKLOG_SMTP_USER` | *(required)* | SMTP authentication username | +| `HACKLOG_SMTP_PASSWORD` | *(required)* | SMTP authentication password | +| `HACKLOG_SMTP_SENDER` | *(required)* | From address for alert emails | +| `HACKLOG_ALERT_RECIPIENT` | *(required)* | Destination address for alert emails | -Start the service -``service hacklog start`` +### Syslog Listener -Point to your syslog output to ``@`` +| Variable | Default | Description | +|----------|---------|-------------| +| `HACKLOG_SYSLOG_BIND_ADDRESS` | `127.0.0.1` | UDP listener bind address | +| `HACKLOG_SYSLOG_PORT` | `10514` | UDP listener port | +| `HACKLOG_SYSLOG_MAX_MESSAGE_SIZE` | `2048` | Max syslog datagram size (bytes) | +| `HACKLOG_SYSLOG_RATE_LIMIT_PER_SOURCE` | `100` | Max messages per source IP per second | +| `HACKLOG_ALLOWED_CIDRS` | *(allow all)* | Comma-separated CIDR allowlist for syslog sources | +### Database -Community -========= +| Variable | Default | Description | +|----------|---------|-------------| +| `HACKLOG_DATABASE_DB_URL` | `sqlite:///hacklog.db` | SQLAlchemy database URL | +| `HACKLOG_DATABASE_POOL_SIZE` | `5` | SQLAlchemy connection pool size | + +### Data Retention + +| Variable | Default | Description | +|----------|---------|-------------| +| `HACKLOG_EVENT_RETENTION_DAYS` | `365` | Days to retain event log records before physical deletion | +| `HACKLOG_PROFILE_INACTIVITY_DAYS` | `180` | Days of inactivity after which user profiles are purged | +| `HACKLOG_PURGE_SCHEDULE_HOUR` | `2` | UTC hour at which the daily purge runs (0–23) | +| `HACKLOG_PURGE_BATCH_SIZE` | `1000` | Records deleted per batch to avoid long SQLite transactions | + +### Scoring Weights + +All weights are integers in the range 0–100. Higher values make the corresponding dimension contribute more to the risk score. + +| Variable | Default | Description | +|----------|---------|-------------| +| `HACKLOG_SCORING_HOURS_WEIGHT` | `10` | Weight for time-of-day anomaly | +| `HACKLOG_SCORING_DAYS_WEIGHT` | `10` | Weight for day-of-week anomaly | +| `HACKLOG_SCORING_SERVER_WEIGHT` | `15` | Weight for unusual server target | +| `HACKLOG_SCORING_SUCCESS_WEIGHT` | `35` | Weight for authentication failure | +| `HACKLOG_SCORING_VPN_WEIGHT` | `0` | Weight for VPN source IP | +| `HACKLOG_SCORING_INTERNAL_WEIGHT` | `10` | Weight for internal (RFC-1918) source IP | +| `HACKLOG_SCORING_EXTERNAL_WEIGHT` | `15` | Weight for external source IP | +| `HACKLOG_SCORING_IP_WEIGHT` | `15` | Weight for unusual source IP frequency | + +### Alert Thresholds + +| Variable | Default | Description | +|----------|---------|-------------| +| `HACKLOG_SCORING_CRITICAL_THRESHOLD` | `50` | Score above which an alert is sent immediately | +| `HACKLOG_SCORING_SCARY_THRESHOLD` | `30` | Score above which the scare counter is incremented | +| `HACKLOG_SCORING_SCARE_COUNT_LIMIT` | `2` | Repeated scary events before an alert is triggered | +| `HACKLOG_SCORING_SCARE_DATE_EXPIRE_DAYS` | `1` | Days of inactivity before the scare counter resets | + +### Observability + +| Variable | Default | Description | +|----------|---------|-------------| +| `HACKLOG_METRICS_ENABLED` | `false` | Expose Prometheus `/metrics` endpoint | +| `HACKLOG_METRICS_PORT` | `9090` | HTTP port for the Prometheus metrics endpoint | +| `HACKLOG_DEAD_LETTER_PATH` | `dead_letter.jsonl` | Path for failed-alert dead-letter queue | + +--- + +## Scoring Algorithm + +Hacklog uses a **weighted surprisal model**: each authentication event is scored by measuring how unusual it is relative to the user's historical baseline. The final score is the sum of six dimension sub-scores. + +### Dimensions + +For each frequency-based dimension (time of day, day of week, server, source IP), the sub-score is calculated as: + +``` +sub_score = -log₂(frequency) × weight +``` + +Where `frequency` is the fraction of times this user has been seen with the given value (e.g., logging in on a Monday). A first-ever value has frequency near 0, producing a high sub-score. A frequently-seen value has frequency near 1, producing a sub-score near 0. + +The remaining two dimensions are categorical: + +| Dimension | Condition | Score | +|-----------|-----------|-------| +| **Authentication result** | Failure | +35 | +| **Authentication result** | Success | +0 | +| **IP location** | External | +15 | +| **IP location** | Internal (10.24.x, 10.26.x, 172.16.x) | +10 | +| **IP location** | VPN (10.42.x) | +0 | + +### Alert Decision + +After scoring, the engine decides what action to take: + +``` +score > CRITICAL (50) → immediate alert email +score > SCARY (30) + AND scare_count ≥ 2 → immediate alert email +score > SCARY (30) + AND scare_count < 2 → increment scare counter +days since last scary ≥ 1 → reset scare counter +``` + +Every decision (score calculated, alert sent/suppressed, scare counter change) is persisted as an immutable audit record and emitted as a structured log entry. + +### Example + +A user who always logs in on weekdays from a known internal IP, then suddenly logs in on a Sunday from an unknown external IP with a failed password: + +| Dimension | Value | Score | +|-----------|-------|-------| +| Auth failure | yes | +35 | +| External IP | new IP | +15 | +| Day of week | first Sunday | ~10 | +| Hour of day | unusual hour | ~5 | +| Server | familiar server | ~1 | +| Source IP | first external IP | ~15 | +| **Total** | | **~81 → CRITICAL alert** | + +--- + +## Development + +See [CONTRIBUTING.md](CONTRIBUTING.md) for full setup instructions, code style guide, and PR process. + +**Quick reference:** + +```bash +git clone https://github.com/dandb/hacklog.git +cd hacklog +python -m venv .venv && source .venv/bin/activate +pip install -e '.[test,dev]' +pytest tests/ + +# Local server (requires .env with HACKLOG_SMTP_* secrets) +cp .env.example .env && make dev-start +make dev-stop # graceful SIGTERM shutdown +``` -Mailing list +See [CONTRIBUTING.md](CONTRIBUTING.md) for full setup instructions, code style guide, and PR process. -https://groups.google.com/forum/#!forum/hacklog-devel +--- -https://groups.google.com/forum/#!forum/hacklog-users +## License -Chat -[![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/dandb/hacklog?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) +Hacklog is released under the [GNU General Public License v3.0](LICENSE). diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..98aa17d --- /dev/null +++ b/alembic.ini @@ -0,0 +1,149 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/migrations + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = sqlite:///hacklog.db + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/deploy/hacklog.env.example b/deploy/hacklog.env.example new file mode 100644 index 0000000..04e28c7 --- /dev/null +++ b/deploy/hacklog.env.example @@ -0,0 +1,48 @@ +# /etc/hacklog/hacklog.env — hacklog systemd service environment +# +# Installation: +# sudo install -d -o hacklog -g hacklog -m 750 /etc/hacklog +# sudo install -o hacklog -g hacklog -m 600 deploy/hacklog.env.example \ +# /etc/hacklog/hacklog.env +# sudo $EDITOR /etc/hacklog/hacklog.env # fill in required values +# +# This file is loaded by EnvironmentFile= in hacklog.service. +# Restrict permissions to 600 (owner read/write only) to protect secrets. + +# ── Required SMTP secrets ──────────────────────────────────────────────────── +HACKLOG_SMTP_USER= +HACKLOG_SMTP_PASSWORD= +HACKLOG_SMTP_SENDER= +HACKLOG_SMTP_HOST=smtp.gmail.com +HACKLOG_SMTP_PORT=587 +HACKLOG_ALERT_RECIPIENT= + +# ── Database ───────────────────────────────────────────────────────────────── +# systemd creates /var/lib/hacklog/ and grants write access automatically +# (via StateDirectory=hacklog in the unit file). +HACKLOG_DATABASE_DB_URL=sqlite:////var/lib/hacklog/hacklog.db + +# ── Syslog listener (optional overrides) ──────────────────────────────────── +# HACKLOG_SYSLOG_BIND_ADDRESS=127.0.0.1 +# HACKLOG_SYSLOG_PORT=10514 +# HACKLOG_SYSLOG_MAX_MESSAGE_SIZE=2048 +# HACKLOG_SYSLOG_RATE_LIMIT_PER_SOURCE=100 +# HACKLOG_ALLOWED_CIDRS=10.0.0.0/8,192.168.0.0/16 + +# ── Metrics (optional) ─────────────────────────────────────────────────────── +# HACKLOG_METRICS_ENABLED=false +# HACKLOG_METRICS_PORT=9090 + +# ── Scoring weights (optional, defaults match legacy algorithm.py constants) ─ +# HACKLOG_SCORING_HOURS_WEIGHT=10 +# HACKLOG_SCORING_DAYS_WEIGHT=10 +# HACKLOG_SCORING_SERVER_WEIGHT=15 +# HACKLOG_SCORING_SUCCESS_WEIGHT=35 +# HACKLOG_SCORING_VPN_WEIGHT=0 +# HACKLOG_SCORING_INTERNAL_WEIGHT=10 +# HACKLOG_SCORING_EXTERNAL_WEIGHT=15 +# HACKLOG_SCORING_IP_WEIGHT=15 +# HACKLOG_SCORING_CRITICAL_THRESHOLD=50 +# HACKLOG_SCORING_SCARY_THRESHOLD=30 +# HACKLOG_SCORING_SCARE_COUNT_LIMIT=2 +# HACKLOG_SCORING_SCARE_DATE_EXPIRE_DAYS=1 diff --git a/deploy/hacklog.service b/deploy/hacklog.service new file mode 100644 index 0000000..bf989ff --- /dev/null +++ b/deploy/hacklog.service @@ -0,0 +1,60 @@ +[Unit] +Description=Hacklog Security Scoring Daemon +Documentation=https://github.com/dandb/hacklog +After=network.target + +[Service] +Type=simple +User=hacklog +Group=hacklog + +# Load secrets and all HACKLOG_* configuration from the environment file. +# See deploy/hacklog.env.example for required and optional variables. +# Permissions must be 600 owned by hacklog:hacklog to protect secrets. +EnvironmentFile=/etc/hacklog/hacklog.env + +# Start the server using the installed hacklog package. +ExecStart=/usr/bin/python3 -m hacklog.server + +# Restart automatically on non-zero exit, with a 5-second back-off. +Restart=on-failure +RestartSec=5 + +# ── Resource limits ───────────────────────────────────────────────────────── +MemoryMax=512M +CPUQuota=200% + +# ── Security hardening ────────────────────────────────────────────────────── +# Prevent privilege escalation via setuid/setgid binaries. +NoNewPrivileges=yes + +# Mount the OS filesystem read-only (dirs below are exempted automatically). +ProtectSystem=strict + +# Deny access to user home directories. +ProtectHome=yes + +# Provide an isolated /tmp and /var/tmp namespace. +PrivateTmp=yes + +# Allow binding to privileged ports (< 1024) when port 514 is configured. +# Not required for the default port 10514. +AmbientCapabilities=CAP_NET_BIND_SERVICE + +# ── Managed directories ───────────────────────────────────────────────────── +# systemd creates these paths, sets ownership to hacklog:hacklog, and grants +# write access even under ProtectSystem=strict. +# /var/lib/hacklog → SQLite database (set HACKLOG_DATABASE_DB_URL accordingly) +# /var/log/hacklog → dead-letter JSON-lines files written on DB failure +StateDirectory=hacklog +LogsDirectory=hacklog + +# ── Logging ───────────────────────────────────────────────────────────────── +# Route all output to the systemd journal for structured log integration. +# Retrieve with: journalctl -u hacklog -f +StandardOutput=journal +StandardError=journal +SyslogIdentifier=hacklog + +[Install] +WantedBy=multi-user.target diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5fb28b4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,92 @@ +# docker-compose.yml — hacklog development / test environment +# +# Usage: +# cp .env.example .env && $EDITOR .env # fill in SMTP secrets +# docker compose up -d # start hacklog +# docker compose --profile monitoring up -d # start with Prometheus +# +# Required environment variables (set in .env or the environment): +# HACKLOG_SMTP_USER, HACKLOG_SMTP_PASSWORD, HACKLOG_SMTP_SENDER, +# HACKLOG_ALERT_RECIPIENT + +services: + hacklog: + build: + context: . + dockerfile: Dockerfile + image: hacklog:latest + container_name: hacklog + restart: unless-stopped + + # ── Syslog UDP listener ────────────────────────────────────────────────── + ports: + - "${HACKLOG_SYSLOG_PORT:-10514}:10514/udp" + # Expose Prometheus metrics port when metrics are enabled. + - "${HACKLOG_METRICS_PORT:-9090}:9090" + + # ── Persistent volumes ─────────────────────────────────────────────────── + volumes: + - hacklog_data:/data + - hacklog_logs:/var/log/hacklog + + # ── Configuration via environment variables ────────────────────────────── + # Required secrets must be supplied in .env or the host environment. + # Optional overrides are shown commented out with their defaults. + environment: + # SMTP — required secrets + - HACKLOG_SMTP_HOST=${HACKLOG_SMTP_HOST:-smtp.gmail.com} + - HACKLOG_SMTP_PORT=${HACKLOG_SMTP_PORT:-587} + - HACKLOG_SMTP_USER=${HACKLOG_SMTP_USER} + - HACKLOG_SMTP_PASSWORD=${HACKLOG_SMTP_PASSWORD} + - HACKLOG_SMTP_SENDER=${HACKLOG_SMTP_SENDER} + - HACKLOG_ALERT_RECIPIENT=${HACKLOG_ALERT_RECIPIENT} + + # Syslog listener + - HACKLOG_SYSLOG_BIND_ADDRESS=0.0.0.0 + - HACKLOG_SYSLOG_PORT=10514 + # - HACKLOG_SYSLOG_MAX_MESSAGE_SIZE=2048 + # - HACKLOG_SYSLOG_RATE_LIMIT_PER_SOURCE=100 + # - HACKLOG_ALLOWED_CIDRS=10.0.0.0/8,192.168.0.0/16 + + # Database — use the mounted /data volume + - HACKLOG_DATABASE_DB_URL=sqlite:////data/hacklog.db + # - HACKLOG_DATABASE_POOL_SIZE=5 + + # Metrics + - HACKLOG_METRICS_ENABLED=${HACKLOG_METRICS_ENABLED:-false} + - HACKLOG_METRICS_PORT=${HACKLOG_METRICS_PORT:-9090} + + # Scoring weights (defaults match the legacy algorithm.py constants) + # - HACKLOG_SCORING_HOURS_WEIGHT=10 + # - HACKLOG_SCORING_DAYS_WEIGHT=10 + # - HACKLOG_SCORING_SERVER_WEIGHT=15 + # - HACKLOG_SCORING_SUCCESS_WEIGHT=35 + # - HACKLOG_SCORING_CRITICAL_THRESHOLD=50 + # - HACKLOG_SCORING_SCARY_THRESHOLD=30 + + # ── Optional Prometheus monitoring (start with --profile monitoring) ──────── + prometheus: + image: prom/prometheus:latest + container_name: hacklog-prometheus + profiles: + - monitoring + restart: unless-stopped + ports: + - "9091:9090" + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus_data:/prometheus + command: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.retention.time=7d" + depends_on: + - hacklog + +# ── Named volumes ────────────────────────────────────────────────────────────── +volumes: + hacklog_data: + driver: local + hacklog_logs: + driver: local + prometheus_data: + driver: local diff --git a/hacklog.spec b/hacklog.spec index 22e55a5..39d23c6 100644 --- a/hacklog.spec +++ b/hacklog.spec @@ -26,21 +26,17 @@ BuildRoot: %{_tmppath}/%{name}-%{version}-%{release}-root-%(%{__id_u} -n) BuildArch: noarch Requires: python-sqlalchemy -Requires: python-twisted %if 0%{?with_python26} -BuildRequires: python26-twisted BuildRequires: python26-sqlalchemy BuildRequires: python26-setuptools -Requires: python26-twisted Requires: python26-sqlalchemy %else %if ((0%{?rhel} >= 6 || 0%{?fedora} > 12) && 0%{?include_tests}) BuildRequires: python-sqlalchemy -BuildRequires: python-twisted BuildRequires: python-setuptools %endif diff --git a/hacklog/accessdata.py b/hacklog/accessdata.py index db10372..1976942 100644 --- a/hacklog/accessdata.py +++ b/hacklog/accessdata.py @@ -1,50 +1,86 @@ -from sqlalchemy.orm import * -from entities import * -from session import Session +"""Data access layer for hacklog entity persistence (DAO compatibility wrappers).""" + +from collections.abc import Callable + +from entities import EventLog, Profile, ProfileType, User +from repositories import AuditRepository, ProfileRepository, UserRepository +from session import Session as SessionFactory +from sqlalchemy.orm import Session + class GenericDao: + def __init__(self, session_factory: Callable[[], Session] | None = None) -> None: + factory = session_factory or SessionFactory + self._profile_repository = ProfileRepository(factory) + self._user_repository = UserRepository(factory) + self._audit_repository = AuditRepository(factory) + + def save_entity(self, entity: object) -> None: + if isinstance(entity, EventLog): + self._audit_repository.save_event(entity) + elif isinstance(entity, User): + self._user_repository.save(entity) + elif isinstance(entity, Profile): + self._profile_repository.save_profile(entity) + else: + raise TypeError(f"Unsupported entity type: {type(entity).__name__}") - def saveEntity(self, entity): - session = Session() - session.add(entity) - session.commit() + def merge_entity(self, entity: object) -> None: + if isinstance(entity, User): + self._user_repository.merge(entity) + elif isinstance(entity, Profile): + self._profile_repository.update_profile(entity) + else: + raise TypeError( + f"Unsupported entity type for merge: {type(entity).__name__}" + ) - def mergeEntity(self, entity): - session = Session() - session.merge(entity) - session.commit() class UserDao: - - def getUserByName(self, user): - session = Session() - fullUser = session.query(User).filter(User.username == user).first() - return fullUser + def __init__(self, session_factory: Callable[[], Session] | None = None) -> None: + self._user_repository = UserRepository(session_factory or SessionFactory) + + def get_user_by_name(self, user: str) -> User | None: + return self._user_repository.get_by_username(user) + + +class ProfileDao: + def __init__(self, session_factory: Callable[[], Session] | None = None) -> None: + self._profile_repository = ProfileRepository(session_factory or SessionFactory) + + def get_profile_by_user( + self, profile_type: ProfileType, user: str + ) -> Profile | None: + return self._profile_repository.get_profile(profile_type, user) + class DaysDao: - - def getProfileByUser(self, user): - session = Session() - days = session.query(Days).filter(Days.username == user).first() - return days + def __init__(self, session_factory: Callable[[], Session] | None = None) -> None: + self._profile_dao = ProfileDao(session_factory) + + def get_profile_by_user(self, user: str) -> Profile | None: + return self._profile_dao.get_profile_by_user(ProfileType.DAYS, user) + class HoursDao: - - def getProfileByUser(self, user): - session = Session() - hours = session.query(Hours).filter(Hours.username == user).first() - return hours + def __init__(self, session_factory: Callable[[], Session] | None = None) -> None: + self._profile_dao = ProfileDao(session_factory) + + def get_profile_by_user(self, user: str) -> Profile | None: + return self._profile_dao.get_profile_by_user(ProfileType.HOURS, user) + class IpAddressDao: - - def getProfileByUser(self, user): - session = Session() - ipAddresses = session.query(IpAddress).filter(IpAddress.username == user).first() - return ipAddresses + def __init__(self, session_factory: Callable[[], Session] | None = None) -> None: + self._profile_dao = ProfileDao(session_factory) + + def get_profile_by_user(self, user: str) -> Profile | None: + return self._profile_dao.get_profile_by_user(ProfileType.IP_ADDRESS, user) + class ServerDao: - - def getProfileByUser(self, user): - session = Session() - servers = session.query(Servers).filter(Servers.username == user).first() - return servers + def __init__(self, session_factory: Callable[[], Session] | None = None) -> None: + self._profile_dao = ProfileDao(session_factory) + + def get_profile_by_user(self, user: str) -> Profile | None: + return self._profile_dao.get_profile_by_user(ProfileType.SERVER, user) diff --git a/hacklog/alerting.py b/hacklog/alerting.py new file mode 100644 index 0000000..35439aa --- /dev/null +++ b/hacklog/alerting.py @@ -0,0 +1,417 @@ +"""Async alert delivery with circuit breaker, retry, and dead letter queue.""" + +import asyncio +import json +import os +import time +from collections.abc import Awaitable, Callable +from datetime import UTC, datetime +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from enum import Enum +from pathlib import Path +from typing import Any + +import aiosmtplib +from aiosmtplib.errors import SMTPAuthenticationError, SMTPConnectError, SMTPException + +try: + from hacklog.config import SmtpConfig + from hacklog.entities import AuditRecord, EventLog, User + from hacklog.logging_config import get_logger + from hacklog.repositories import AuditRepository +except ImportError: + from config import SmtpConfig + from entities import AuditRecord, EventLog, User + from logging_config import get_logger + from repositories import AuditRepository + +logger = get_logger("alerting") + +DEFAULT_DEAD_LETTER_PATH = "dead_letter.jsonl" +DEFAULT_DEAD_LETTER_MAX_BYTES = 10 * 1024 * 1024 +DEFAULT_FAILURE_THRESHOLD = 5 +DEFAULT_RESET_TIMEOUT_SECONDS = 60.0 +DEFAULT_MAX_RETRY_ATTEMPTS = 3 +DEFAULT_RETRY_BASE_DELAY_SECONDS = 1.0 + +SmtpSender = Callable[[MIMEMultipart, SmtpConfig], Awaitable[None]] + + +class CircuitState(str, Enum): + CLOSED = "closed" + OPEN = "open" + HALF_OPEN = "half_open" + + +class CircuitBreakerOpenError(Exception): + """Raised when the circuit breaker rejects a request.""" + + +class CircuitBreaker: + """SMTP circuit breaker with closed, open, and half-open states.""" + + def __init__( + self, + *, + failure_threshold: int = DEFAULT_FAILURE_THRESHOLD, + reset_timeout: float = DEFAULT_RESET_TIMEOUT_SECONDS, + clock: Callable[[], float] | None = None, + ) -> None: + self.failure_threshold = failure_threshold + self.reset_timeout = reset_timeout + self._clock = clock or time.monotonic + self._state = CircuitState.CLOSED + self._failure_count = 0 + self._opened_at: float | None = None + self._half_open_probe_in_flight = False + self._lock = asyncio.Lock() + + @property + def state(self) -> CircuitState: + return self._state + + async def allow_request(self) -> bool: + async with self._lock: + if self._state == CircuitState.CLOSED: + return True + + if self._state == CircuitState.OPEN: + if ( + self._opened_at is not None + and self._clock() - self._opened_at >= self.reset_timeout + ): + previous = self._state + self._state = CircuitState.HALF_OPEN + self._half_open_probe_in_flight = False + logger.info( + "circuit_breaker_state_change", + operation="circuit_breaker", + previous_state=previous.value, + new_state=self._state.value, + ) + else: + return False + + if self._state == CircuitState.HALF_OPEN: + if self._half_open_probe_in_flight: + return False + self._half_open_probe_in_flight = True + return True + + async def record_success(self) -> None: + async with self._lock: + previous = self._state + if self._state == CircuitState.HALF_OPEN: + self._state = CircuitState.CLOSED + self._failure_count = 0 + self._opened_at = None + self._half_open_probe_in_flight = False + logger.info( + "circuit_breaker_state_change", + operation="circuit_breaker", + previous_state=previous.value, + new_state=self._state.value, + ) + elif self._state == CircuitState.CLOSED: + self._failure_count = 0 + + async def record_failure(self) -> None: + async with self._lock: + previous = self._state + if self._state == CircuitState.HALF_OPEN: + self._state = CircuitState.OPEN + self._opened_at = self._clock() + self._half_open_probe_in_flight = False + logger.warning( + "circuit_breaker_state_change", + operation="circuit_breaker", + previous_state=previous.value, + new_state=self._state.value, + reason="half_open_probe_failed", + ) + return + + self._failure_count += 1 + if self._failure_count >= self.failure_threshold: + self._state = CircuitState.OPEN + self._opened_at = self._clock() + logger.warning( + "circuit_breaker_state_change", + operation="circuit_breaker", + previous_state=previous.value, + new_state=self._state.value, + failure_count=self._failure_count, + ) + + +class DeadLetterWriter: + """Append failed alerts as JSON lines with size-based rotation.""" + + def __init__( + self, + path: str | Path = DEFAULT_DEAD_LETTER_PATH, + *, + max_bytes: int = DEFAULT_DEAD_LETTER_MAX_BYTES, + ) -> None: + self._path = Path(path) + self._max_bytes = max_bytes + self._lock = asyncio.Lock() + + @property + def path(self) -> Path: + return self._path + + async def write(self, payload: dict[str, Any]) -> None: + async with self._lock: + self._rotate_if_needed() + line = json.dumps(payload, default=str) + "\n" + with self._path.open("a", encoding="utf-8") as handle: + handle.write(line) + logger.warning( + "alert_dead_lettered", + operation="dead_letter_write", + path=str(self._path), + username=payload.get("username"), + server=payload.get("server"), + ) + + def _rotate_if_needed(self) -> None: + if not self._path.exists(): + return + if self._path.stat().st_size < self._max_bytes: + return + rotated = self._path.with_suffix( + f".{datetime.now(UTC).strftime('%Y%m%d%H%M%S')}.jsonl" + ) + self._path.rename(rotated) + logger.info( + "dead_letter_rotated", + operation="dead_letter_rotate", + previous_path=str(self._path), + rotated_path=str(rotated), + ) + + +def _format_alert_timestamp(event_log: EventLog) -> str: + event_date = event_log.date + if isinstance(event_date, datetime): + return event_date.isoformat() + return str(event_date) + + +def build_alert_message( + user: User, + event_log: EventLog, + *, + sender: str, + recipient: str, +) -> MIMEMultipart: + timestamp = _format_alert_timestamp(event_log) + msg = MIMEMultipart() + msg["Subject"] = "EMAIL ALERT - CONCERNING SSH ACTIVITY ON: " + event_log.server + msg["From"] = sender + msg["To"] = recipient + text = ( + "Hi!\nHow are you?\nThere was some suspicious activity on the following server: " + + event_log.server + + " for user: " + + user.username + + "\n Their current score is " + + str(user.score) + + "\nTimestamp: " + + timestamp + ) + msg.attach(MIMEText(text, "plain")) + return msg + + +async def default_smtp_sender(message: MIMEMultipart, smtp_config: SmtpConfig) -> None: + await aiosmtplib.send( + message, + hostname=smtp_config.host, + port=smtp_config.port, + username=smtp_config.username, + password=smtp_config.password.get_secret_value(), + start_tls=smtp_config.use_tls, + ) + + +def is_transient_smtp_error(exc: BaseException) -> bool: + if isinstance(exc, (SMTPConnectError, TimeoutError, OSError, ConnectionError)): + return True + if isinstance(exc, SMTPException) and not isinstance(exc, SMTPAuthenticationError): + return True + return False + + +class AlertService: + """Async SMTP alert delivery with circuit breaker and retry logic.""" + + def __init__( + self, + smtp_config: SmtpConfig | None, + *, + circuit_breaker: CircuitBreaker | None = None, + dead_letter_writer: DeadLetterWriter | None = None, + smtp_sender: SmtpSender | None = None, + max_retry_attempts: int = DEFAULT_MAX_RETRY_ATTEMPTS, + retry_base_delay_seconds: float = DEFAULT_RETRY_BASE_DELAY_SECONDS, + dead_letter_path: str | Path | None = None, + audit_repository: AuditRepository | None = None, + ) -> None: + if smtp_config is None: + raise TypeError("AlertService requires SmtpConfig from ConfigManager") + if not isinstance(smtp_config, SmtpConfig): + raise TypeError("AlertService requires SmtpConfig from ConfigManager") + self._smtp_config = smtp_config + self.from_address = smtp_config.sender + self.recipient = smtp_config.recipient + self.mail_server = None + self._circuit = circuit_breaker or CircuitBreaker() + if dead_letter_writer is not None: + self._dead_letter = dead_letter_writer + else: + path = dead_letter_path or os.environ.get( + "HACKLOG_DEAD_LETTER_PATH", DEFAULT_DEAD_LETTER_PATH + ) + self._dead_letter = DeadLetterWriter(path) + self._smtp_sender = smtp_sender or default_smtp_sender + self._max_retry_attempts = max_retry_attempts + self._retry_base_delay_seconds = retry_base_delay_seconds + self._audit_repository = audit_repository + + def _emit_audit_record( + self, + user: User, + event_log: EventLog, + action: str, + reason: str, + ) -> None: + """Emit an audit event as a structured log entry and optionally persist it.""" + timestamp = datetime.now(UTC) + logger.info( + "audit_event", + audit=True, + actor=user.username, + action=action, + source_ip=event_log.ip_address, + resource=event_log.server, + outcome=action, + details={"reason": reason, "score": user.score}, + timestamp=timestamp.isoformat(), + ) + if self._audit_repository is not None: + record = AuditRecord( + timestamp=timestamp, + actor=user.username, + source_ip=event_log.ip_address, + resource=event_log.server, + action=action, + outcome=action, + details={"reason": reason, "score": user.score}, + ) + self._audit_repository.save_audit_record(record) + + async def send_alert(self, user: User, event_log: EventLog) -> None: + if not await self._circuit.allow_request(): + logger.warning( + "alert_rejected_circuit_open", + operation="send_alert", + username=user.username, + server=event_log.server, + circuit_state=self._circuit.state.value, + ) + await self._dead_letter.write( + self._dead_letter_payload(user, event_log, reason="circuit_open") + ) + self._emit_audit_record(user, event_log, "alert_suppressed", "circuit_open") + return + + logger.info( + "alert_send_attempt", + operation="send_alert", + username=user.username, + source_ip=event_log.ip_address, + server=event_log.server, + score=user.score, + recipient=self.recipient, + circuit_state=self._circuit.state.value, + ) + + message = build_alert_message( + user, + event_log, + sender=self.from_address, + recipient=self.recipient, + ) + + last_error: BaseException | None = None + for attempt in range(1, self._max_retry_attempts + 1): + try: + await self._smtp_sender(message, self._smtp_config) + await self._circuit.record_success() + logger.info( + "alert_send_success", + operation="send_alert", + username=user.username, + server=event_log.server, + score=user.score, + attempt=attempt, + circuit_state=self._circuit.state.value, + ) + self._emit_audit_record(user, event_log, "alert_sent", "smtp_success") + return + except Exception as exc: + last_error = exc + transient = is_transient_smtp_error(exc) + logger.warning( + "alert_send_failure", + operation="send_alert", + username=user.username, + server=event_log.server, + attempt=attempt, + transient=transient, + error=str(exc), + circuit_state=self._circuit.state.value, + ) + if not transient or attempt >= self._max_retry_attempts: + break + delay = self._retry_base_delay_seconds * (2 ** (attempt - 1)) + await asyncio.sleep(delay) + + await self._circuit.record_failure() + reason = str(last_error) if last_error else "unknown_error" + await self._dead_letter.write( + self._dead_letter_payload( + user, + event_log, + reason=reason, + ) + ) + self._emit_audit_record(user, event_log, "alert_suppressed", reason) + + def send_email_alert(self, user: User, event_log: EventLog) -> None: + """Sync adapter for the legacy scoring pipeline.""" + try: + loop = asyncio.get_running_loop() + except RuntimeError: + asyncio.run(self.send_alert(user, event_log)) + else: + loop.create_task(self.send_alert(user, event_log)) + + @staticmethod + def _dead_letter_payload( + user: User, + event_log: EventLog, + *, + reason: str, + ) -> dict[str, Any]: + return { + "username": user.username, + "server": event_log.server, + "score": user.score, + "timestamp": _format_alert_timestamp(event_log), + "source_ip": event_log.ip_address, + "reason": reason, + } diff --git a/hacklog/algorithm.py b/hacklog/algorithm.py deleted file mode 100644 index d80e01f..0000000 --- a/hacklog/algorithm.py +++ /dev/null @@ -1,98 +0,0 @@ -import services -from entities import EventLog -from entities import enum -from entities import IpAddress -import math -from datetime import datetime, timedelta -import logging - -Weight = enum(HOURS=10, DAYS=10, SERVER=15, SUCCESS=35, VPN=0, INT=10, EXT=15, IP=15) -Threshold = enum(CRITICAL=50, SCARY=30, SCARECOUNT=2, SCAREDATEEXPIRE=1) - -updateService = None -emailService = None - -def setServices(conf=None): - global updateService - global emailService - updateService = services.UpdateService(conf) - emailService = services.EmailService(conf) - -def testProcess(): - eventLog = EventLog(date.today(), 'nrhine', '127.0.0.1', True, 'ae1-app80-prd') - processEventLog(eventLog) - -def processEventLog(eventLog): - auditEventLog(eventLog) - score = calculateNewScore(eventLog) - user = updateService.fetchUser(eventLog) - timeDiff = eventLog.date - user.lastScareDate - updateService.updateUserScore(user, score) - if score > Threshold.CRITICAL: - processAlert(user, eventLog) - elif score > Threshold.SCARY: - if user.scareCount >= Threshold.SCARECOUNT: - processAlert(user, eventLog) - user = updateService.updateUserScareCount(user) - elif abs(timeDiff.days) >= Threshold.SCAREDATEEXPIRE: - updateService.resetUserScareCount(user) - -def calculateNewScore(eventLog): - successScore = calculateSuccessScore(eventLog.success) - ipLocationScore = calculateIpLocationScore(eventLog.ipAddress) - - serverScore = calculateServerScore(eventLog) - ipScore = calculateIpScore(eventLog) - dayScore = calculateDaysScore(eventLog) - hourScore = calculateHoursScore(eventLog) - - totalScore = successScore + ipLocationScore + serverScore + ipScore + dayScore + hourScore - logging.debug("Total Score: %s" % totalScore) - return totalScore - -def auditEventLog(eventLog): - updateService.auditEventLog(eventLog) - -def processAlert(user, eventLog): - emailService.sendEmailAlert(user, eventLog) - -def calculateHoursScore(eventLog): - hourFreq = updateService.updateAndReturnHourFreqForUser(eventLog) - hourScore = calculateSubscore(hourFreq)*Weight.HOURS - return hourScore - -def calculateDaysScore(eventLog): - dayFreq = updateService.updateAndReturnDayFreqForUser(eventLog) - dayScore = calculateSubscore(dayFreq)*Weight.DAYS - return dayScore - -def calculateServerScore(eventLog): - serverFreq = updateService.updateAndReturnServerFreqForUser(eventLog) - serverScore = calculateSubscore(serverFreq) * Weight.SERVER - return serverScore - -def calculateIpScore(eventLog): - ipFreq = updateService.updateAndReturnIpFreqForUser(eventLog) - ipScore = calculateSubscore(ipFreq) * Weight.IP - return ipScore - -def calculateSubscore(freq): - subscore = math.log(freq, 2) - subscore = subscore*-10 - if subscore>100 : - return 100 - return float(subscore)/100 - -def calculateSuccessScore(success): - successScore = Weight.SUCCESS - if success: - successScore = 0 - return successScore - -def calculateIpLocationScore(ipAddress): - ipScore = Weight.EXT - if IpAddress.checkIpForVpn(ipAddress): - ipScore=Weight.VPN - if IpAddress.checkIpForInternal(ipAddress): - ipScore=Weight.INT - return ipScore diff --git a/hacklog/config.py b/hacklog/config.py new file mode 100644 index 0000000..a49615c --- /dev/null +++ b/hacklog/config.py @@ -0,0 +1,438 @@ +"""Centralized configuration management for hacklog.""" + +import os +from pathlib import Path +from typing import Any + +import yaml +from pydantic import BaseModel, Field, ValidationError, field_validator +from pydantic.types import SecretStr +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class SyslogConfig(BaseModel): + """UDP syslog listener settings.""" + + bind_address: str = Field( + default="127.0.0.1", + description="Network address the syslog UDP listener binds to.", + ) + port: int = Field( + default=10514, + ge=1, + le=65535, + description="UDP port for incoming syslog messages.", + ) + max_message_size: int = Field( + default=2048, + ge=512, + le=65535, + description="Maximum syslog datagram size accepted in bytes.", + ) + allowed_cidrs: list[str] = Field( + default_factory=list, + description="CIDR blocks allowed to send syslog messages to this listener.", + ) + rate_limit_per_source: int = Field( + default=100, + ge=1, + description="Maximum syslog messages accepted per source IP per second.", + ) + + +class SmtpConfig(BaseSettings): + """SMTP alert delivery settings loaded from environment variables.""" + + model_config = SettingsConfigDict( + env_file=None, + extra="ignore", + populate_by_name=True, + ) + + host: str = Field( + default="smtp.gmail.com", + validation_alias="HACKLOG_SMTP_HOST", + description="SMTP server hostname used for alert delivery.", + ) + port: int = Field( + default=587, + validation_alias="HACKLOG_SMTP_PORT", + ge=1, + le=65535, + description="SMTP server port.", + ) + username: str = Field( + validation_alias="HACKLOG_SMTP_USER", + description="SMTP authentication username.", + ) + password: SecretStr = Field( + validation_alias="HACKLOG_SMTP_PASSWORD", + description="SMTP authentication password (required secret).", + ) + use_tls: bool = Field( + default=True, + description="Enable STARTTLS when connecting to the SMTP server.", + ) + sender: str = Field( + validation_alias="HACKLOG_SMTP_SENDER", + description="From address used when sending alert emails.", + ) + recipient: str = Field( + validation_alias="HACKLOG_ALERT_RECIPIENT", + description="Destination address for security alert emails.", + ) + + @field_validator("password") + @classmethod + def validate_password_not_empty(cls, value: SecretStr) -> SecretStr: + if not value.get_secret_value().strip(): + raise ValueError("HACKLOG_SMTP_PASSWORD environment variable is required") + return value + + +class ScoringConfig(BaseModel): + """Scoring engine weights and alert thresholds.""" + + hours_weight: int = Field( + default=10, + ge=0, + le=100, + description=( + "HOURS_WEIGHT: Weight applied to time-of-day anomaly sub-score. " + "Range: 0-100. Higher values increase sensitivity to unusual login times. Default: 10" + ), + ) + days_weight: int = Field( + default=10, + ge=0, + le=100, + description=( + "DAYS_WEIGHT: Weight applied to day-of-week anomaly sub-score. " + "Range: 0-100. Higher values increase sensitivity to unusual login days. Default: 10" + ), + ) + server_weight: int = Field( + default=15, + ge=0, + le=100, + description=( + "SERVER_WEIGHT: Weight applied to server access anomaly sub-score. " + "Range: 0-100. Higher values increase sensitivity to unusual server targets. Default: 15" + ), + ) + success_weight: int = Field( + default=35, + ge=0, + le=100, + description=( + "SUCCESS_WEIGHT: Weight applied to authentication success/failure sub-score. " + "Range: 0-100. Higher values increase sensitivity to failed login patterns. Default: 35" + ), + ) + vpn_weight: int = Field( + default=0, + ge=0, + le=100, + description=( + "VPN_WEIGHT: Weight applied to VPN-related location sub-score. " + "Range: 0-100. Higher values increase VPN anomaly contribution. Default: 0" + ), + ) + internal_weight: int = Field( + default=10, + ge=0, + le=100, + description=( + "INTERNAL_WEIGHT: Weight applied to internal IP location sub-score. " + "Range: 0-100. Higher values increase sensitivity to internal IP anomalies. Default: 10" + ), + ) + external_weight: int = Field( + default=15, + ge=0, + le=100, + description=( + "EXTERNAL_WEIGHT: Weight applied to external IP location sub-score. " + "Range: 0-100. Higher values increase sensitivity to external IP anomalies. Default: 15" + ), + ) + ip_weight: int = Field( + default=15, + ge=0, + le=100, + description=( + "IP_WEIGHT: Weight applied to source IP frequency sub-score. " + "Range: 0-100. Higher values increase sensitivity to unusual source IPs. Default: 15" + ), + ) + critical_threshold: int = Field( + default=50, + ge=0, + le=1000, + description=( + "CRITICAL_THRESHOLD: Total score above which an immediate alert is sent. " + "Range: 0-1000. Lower values trigger alerts sooner. Default: 50" + ), + ) + scary_threshold: int = Field( + default=30, + ge=0, + le=1000, + description=( + "SCARY_THRESHOLD: Total score above which scare-count escalation begins. " + "Range: 0-1000. Lower values escalate repeated anomalies sooner. Default: 30" + ), + ) + scare_count_limit: int = Field( + default=2, + ge=1, + le=100, + description=( + "SCARE_COUNT_LIMIT: Number of scary events before an alert is sent. " + "Range: 1-100. Lower values alert after fewer repeated anomalies. Default: 2" + ), + ) + scare_date_expire_days: int = Field( + default=1, + ge=0, + le=365, + description=( + "SCARE_DATE_EXPIRE_DAYS: Days after which user scare count resets. " + "Range: 0-365. Lower values reset escalation counters sooner. Default: 1" + ), + ) + + +class RetentionConfig(BaseModel): + """Data retention and automated purge settings.""" + + event_retention_days: int = Field( + default=365, + ge=1, + le=3650, + description=( + "HACKLOG_EVENT_RETENTION_DAYS: Days to retain event log records. " + "Records older than this are physically deleted. Default: 365" + ), + ) + profile_inactivity_days: int = Field( + default=180, + ge=1, + le=3650, + description=( + "HACKLOG_PROFILE_INACTIVITY_DAYS: Days of inactivity after which user " + "profiles are purged. Default: 180" + ), + ) + purge_schedule_hour: int = Field( + default=2, + ge=0, + le=23, + description="UTC hour at which the daily purge job runs. Default: 2 (02:00 UTC)", + ) + purge_batch_size: int = Field( + default=1000, + ge=1, + le=100000, + description="Number of records to delete per batch to avoid long transactions. Default: 1000", + ) + + +class DatabaseConfig(BaseModel): + """Database connection settings.""" + + db_url: str = Field( + default="sqlite:///hacklog.db", + description="SQLAlchemy database URL for persistent storage.", + ) + pool_size: int = Field( + default=5, + ge=1, + le=100, + description="SQLAlchemy connection pool size.", + ) + + +class SecurityConfig(BaseModel): + """Security boundary settings.""" + + allowed_source_cidrs: list[str] = Field( + default_factory=lambda: ["0.0.0.0/0"], + description="CIDR blocks permitted to originate syslog traffic.", + ) + + +class _ScoringSettings(BaseSettings): + model_config = SettingsConfigDict(env_prefix="HACKLOG_SCORING_", extra="ignore") + + hours_weight: int | None = None + days_weight: int | None = None + server_weight: int | None = None + success_weight: int | None = None + vpn_weight: int | None = None + internal_weight: int | None = None + external_weight: int | None = None + ip_weight: int | None = None + critical_threshold: int | None = None + scary_threshold: int | None = None + scare_count_limit: int | None = None + scare_date_expire_days: int | None = None + + +class _SyslogSettings(BaseSettings): + model_config = SettingsConfigDict(env_prefix="HACKLOG_SYSLOG_", extra="ignore") + + bind_address: str | None = None + port: int | None = None + max_message_size: int | None = None + allowed_cidrs: list[str] | None = None + rate_limit_per_source: int | None = None + + +class _DatabaseSettings(BaseSettings): + model_config = SettingsConfigDict(env_prefix="HACKLOG_DATABASE_", extra="ignore") + + db_url: str | None = None + pool_size: int | None = None + + +class _SecuritySettings(BaseSettings): + model_config = SettingsConfigDict(env_prefix="HACKLOG_SECURITY_", extra="ignore") + + allowed_source_cidrs: list[str] | None = None + + +class _RetentionSettings(BaseSettings): + """Reads retention env vars using HACKLOG_ prefix.""" + + model_config = SettingsConfigDict(env_prefix="HACKLOG_", extra="ignore") + + event_retention_days: int | None = None + profile_inactivity_days: int | None = None + purge_schedule_hour: int | None = None + purge_batch_size: int | None = None + + +class ConfigManager: + """Validated hacklog configuration assembled from YAML and environment variables.""" + + def __init__( + self, + syslog: SyslogConfig, + smtp: SmtpConfig, + scoring: ScoringConfig, + database: DatabaseConfig, + security: SecurityConfig, + retention: RetentionConfig | None = None, + ) -> None: + self.syslog = syslog + self.smtp = smtp + self.scoring = scoring + self.database = database + self.security = security + self.retention = retention or RetentionConfig() + + +def _load_yaml(path: Path | None) -> dict[str, Any]: + if path is None or not path.is_file(): + return {} + with path.open(encoding="utf-8") as handle: + data = yaml.safe_load(handle) + if data is None: + return {} + if not isinstance(data, dict): + raise ValueError( + f"Configuration file {path} must contain a YAML mapping at the top level." + ) + return data + + +def _merge_non_null(base: BaseModel, overrides: dict[str, Any]) -> BaseModel: + merged = base.model_dump() + for key, value in overrides.items(): + if value is not None: + merged[key] = value + return base.model_validate(merged) + + +def load_config(yaml_path: str | Path | None = None) -> ConfigManager: + """Load and validate hacklog configuration. + + Environment variables take precedence over values from the optional YAML file. + """ + path = Path(yaml_path) if yaml_path is not None else None + yaml_data = _load_yaml(path) + + syslog = _merge_non_null( + SyslogConfig(**yaml_data.get("syslog", {})), + _SyslogSettings().model_dump(), + ) + env_allowed_cidrs = os.environ.get("HACKLOG_ALLOWED_CIDRS", "").strip() + if env_allowed_cidrs: + syslog = syslog.model_copy( + update={ + "allowed_cidrs": [ + entry.strip() + for entry in env_allowed_cidrs.split(",") + if entry.strip() + ] + } + ) + scoring = _merge_non_null( + ScoringConfig(**yaml_data.get("scoring", {})), + _ScoringSettings().model_dump(), + ) + database = _merge_non_null( + DatabaseConfig(**yaml_data.get("database", {})), + _DatabaseSettings().model_dump(), + ) + security = _merge_non_null( + SecurityConfig(**yaml_data.get("security", {})), + _SecuritySettings().model_dump(), + ) + smtp_yaml = yaml_data.get("smtp", {}) + smtp = SmtpConfig(**smtp_yaml) + + retention = _merge_non_null( + RetentionConfig(**yaml_data.get("retention", {})), + _RetentionSettings().model_dump(), + ) + + return ConfigManager( + syslog=syslog, + smtp=smtp, + scoring=scoring, + database=database, + security=security, + retention=retention, + ) + + +REQUIRED_SMTP_PASSWORD_MESSAGE = ( + "HACKLOG_SMTP_PASSWORD environment variable is required" +) + + +def _validation_error_is_missing_smtp_password(exc: ValidationError) -> bool: + for error in exc.errors(): + location = error.get("loc", ()) + if location and location[-1] in ("password", "HACKLOG_SMTP_PASSWORD"): + return True + message = str(error.get("msg", "")) + if "HACKLOG_SMTP_PASSWORD" in message: + return True + if error.get("type") == "missing" and any( + part in ("password", "HACKLOG_SMTP_PASSWORD") for part in location + ): + return True + return False + + +def load_config_or_exit(yaml_path: str | Path | None = None) -> ConfigManager: + """Load configuration and exit with an actionable message when SMTP secrets are missing.""" + try: + return load_config(yaml_path) + except ValidationError as exc: + if _validation_error_is_missing_smtp_password(exc): + raise SystemExit(REQUIRED_SMTP_PASSWORD_MESSAGE) from exc + raise diff --git a/hacklog/entities.py b/hacklog/entities.py index d356cf4..e062aca 100644 --- a/hacklog/entities.py +++ b/hacklog/entities.py @@ -1,137 +1,190 @@ -from sqlalchemy import * -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import sessionmaker -from datetime import date, datetime -from session import Session +"""SQLAlchemy entity models and shared constants for hacklog.""" -db = None -Base = declarative_base() +from datetime import datetime +from enum import IntEnum, StrEnum +from typing import Any -def enum(**enums): - return type('Enum', (), enums) +from sqlalchemy import JSON, Boolean, Column, DateTime, Integer, String, create_engine +from sqlalchemy.engine import Engine +from sqlalchemy.ext.mutable import MutableDict +from sqlalchemy.orm import DeclarativeBase -def create_db_engine(server): - global db - db = create_engine('sqlite:///' + server.dbFile) -def create_tables(): - Base.metadata.create_all(db) - Session.configure(bind=db) +class Base(DeclarativeBase): + pass -class EventLog(Base): - __tablename__ = 'eventLog' - date = Column('date', DateTime, primary_key=True) - username = Column('username', String, primary_key=True) - ipAddress = Column('ipAddress', String) - success = Column('success', Boolean) - server = Column('server', String) +MutableProfile = MutableDict.as_mutable(JSON) + + +class Weight(IntEnum): + HOURS = 10 + DAYS = 10 + SERVER = 15 + SUCCESS = 35 + VPN = 0 + INT = 10 + EXT = 15 + IP = 15 + + +class Threshold(IntEnum): + CRITICAL = 50 + SCARY = 30 + SCARECOUNT = 2 + SCAREDATEEXPIRE = 1 + + +class ProfileType(StrEnum): + """Discriminator for consolidated user behavior profiles.""" + + DAYS = "days" + HOURS = "hours" + SERVER = "server" + IP_ADDRESS = "ipAddress" + + +def create_db_engine(server: Any) -> Engine: + """Create and return the SQLAlchemy engine for the configured database file.""" + return create_engine("sqlite:///" + server.db_file) + + +def create_tables(engine: Engine) -> None: + """Create all entity tables on the given engine.""" + Base.metadata.create_all(engine) + + +class EventLog(Base): + __tablename__ = "eventLog" + + date = Column("date", DateTime, primary_key=True) + username = Column("username", String, primary_key=True) + ip_address = Column("ipAddress", String) + success = Column("success", Boolean) + server = Column("server", String) + + def __init__( + self, + date: datetime, + username: str, + ip_address: str, + success: bool, + server: str, + ) -> None: + self.date = date + self.username = username + self.ip_address = ip_address + self.success = success + self.server = server - def __init__(self, date, username, ipAddress, success, server): - self.date = date - self.username = username - self.ipAddress = ipAddress - self.success = success - self.server = server class User(Base): - __tablename__ = 'users' - - username = Column('username', String, primary_key=True) - date = Column('date', DateTime) - score = Column('score', Integer) - scareCount = Column('scareCount', Integer) - lastScareDate = Column('lastScareDate', DateTime) - - def __init__(self, username, date, score): - self.username=username - self.date=date - self.score=score - self.scareCount=0 - self.lastScareDate = date.today() - -class Days(Base): - __tablename__ = 'days' - - date = Column('date', DateTime, primary_key=True) - username = Column('username', String, primary_key=True) - profile = Column('profile', PickleType) - totalCount = Column('totalCount', Integer) - - def __init__(self, date, username, profile, totalCount): - self.date=date - self.username=username - self.profile = profile - self.totalCount = totalCount - -class Hours(Base): - __tablename__ = 'hours' - - date = Column('date', DateTime, primary_key=True) - username = Column('username', String, primary_key=True) - profile = Column('profile', PickleType) - totalCount = Column('totalCount', Integer) - - def __init__(self, date, username, profile, totalCount): - self.date=date - self.username=username - self.profile = profile - self.totalCount = totalCount - -class Servers(Base): - __tablename__ = 'servers' - - date = Column('date', DateTime, primary_key=True) - username = Column('username', String, primary_key=True) - profile = Column('profile', PickleType) - totalCount = Column('totalCount', Integer) - - def __init__(self, date, username, profile, totalCount): - self.date=date - self.username=username - self.profile = profile - self.totalCount = totalCount - -class IpAddress(Base): - __tablename__ = 'ipAddress' - - date = Column('date', DateTime, primary_key=True) - username = Column('username', String, primary_key=True) - profile = Column('profile', PickleType) - totalCount = Column('totalCount', Integer) - - def __init__(self, date, username, profile, totalCount): - self.date=date - self.username=username - self.profile = profile - self.totalCount = totalCount - - @staticmethod - def checkIpForVpn(ip): - quadrantList = ip.split('.') - if quadrantList[0] == '10' and quadrantList[1] == '42': - return True - return False - - @staticmethod - def checkIpForInternal(ip): - quadrantList = ip.split('.') - if quadrantList[0] == '10': - if quadrantList[1] == '24' or quadrantList[1] == '26': - return True - elif quadrantList[0] == '172' and quadrantList[1] == '16': - return True - return False - -class SyslogMsg(): - - def __init__(self, data='', host='', port=0): - self.data = data - self.host = host - self.port = port - self.date = datetime.now() - -class MailConf(): - - def __init__(self, emailTest=False): - self.emailTest = emailTest + __tablename__ = "users" + + username = Column("username", String, primary_key=True) + date = Column("date", DateTime) + score = Column("score", Integer) + scare_count = Column("scareCount", Integer) + last_scare_date = Column("lastScareDate", DateTime) + + def __init__(self, username: str, date: datetime, score: int) -> None: + self.username = username + self.date = date + self.score = score + self.scare_count = 0 + self.last_scare_date = date.today() + + +class Profile(Base): + """Unified frequency profile for day, hour, server, and IP dimensions.""" + + __tablename__ = "profiles" + + profile_type = Column("profileType", String, primary_key=True) + username = Column("username", String, primary_key=True) + date = Column("date", DateTime) + profile = Column("profile", MutableProfile) + total_count = Column("totalCount", Integer) + + def __init__( + self, + date: datetime, + username: str, + profile_type: ProfileType | str, + profile: dict[str, int], + total_count: int, + ) -> None: + self.date = date + self.username = username + self.profile_type = ( + profile_type.value + if isinstance(profile_type, ProfileType) + else profile_type + ) + self.profile = profile + self.total_count = total_count + + +class IpLocation: + """IP address classification helpers (formerly on IpAddress profile entity).""" + + @staticmethod + def check_ip_for_vpn(ip: str) -> bool: + quadrant_list = ip.split(".") + return quadrant_list[0] == "10" and quadrant_list[1] == "42" + + @staticmethod + def check_ip_for_internal(ip: str) -> bool: + quadrant_list = ip.split(".") + if quadrant_list[0] == "10": + if quadrant_list[1] == "24" or quadrant_list[1] == "26": + return True + elif quadrant_list[0] == "172" and quadrant_list[1] == "16": + return True + return False + + +class SyslogMsg: + def __init__(self, data: str = "", host: str = "", port: int = 0) -> None: + self.data = data + self.host = host + self.port = port + self.date = datetime.now() + + +class AuditRecord(Base): + """Append-only audit record for scoring and alerting events.""" + + __tablename__ = "audit_records" + + id = Column("id", Integer, primary_key=True, autoincrement=True) + timestamp = Column("timestamp", DateTime, nullable=False) + actor = Column("actor", String, nullable=False) + source_ip = Column("source_ip", String, nullable=True) + resource = Column("resource", String, nullable=True) + action = Column("action", String, nullable=False) + outcome = Column("outcome", String, nullable=True) + details = Column("details", JSON, nullable=True) + + def __init__( + self, + timestamp: datetime, + actor: str, + source_ip: str | None, + resource: str | None, + action: str, + outcome: str | None = None, + details: dict[str, Any] | None = None, + ) -> None: + self.timestamp = timestamp + self.actor = actor + self.source_ip = source_ip + self.resource = resource + self.action = action + self.outcome = outcome + self.details = details + + +class MailConf: + def __init__(self, email_test: bool = False) -> None: + self.email_test = email_test diff --git a/hacklog/logging_config.py b/hacklog/logging_config.py new file mode 100644 index 0000000..4aaa407 --- /dev/null +++ b/hacklog/logging_config.py @@ -0,0 +1,149 @@ +"""Structured logging configuration for hacklog using structlog.""" + +import json +import logging +import re +import sys +from typing import Any + +import structlog +from pydantic.types import SecretStr + +_SENSITIVE_KEY_PATTERN = re.compile( + r"password|secret|token|credential|api_key", + re.IGNORECASE, +) + +_MASK_PII = False + + +def _mask_value(value: str) -> str: + if len(value) <= 4: + return "****" + return f"{value[:2]}****{value[-2:]}" + + +def _redact_secrets( + _logger: Any, + _method_name: str, + event_dict: dict[str, Any], +) -> dict[str, Any]: + redacted: dict[str, Any] = {} + for key, value in event_dict.items(): + if _SENSITIVE_KEY_PATTERN.search(key): + redacted[key] = "***REDACTED***" + elif isinstance(value, SecretStr): + redacted[key] = "***REDACTED***" + elif isinstance(value, dict): + redacted[key] = { + nested_key: ( + "***REDACTED***" + if _SENSITIVE_KEY_PATTERN.search(nested_key) + else nested_value + ) + for nested_key, nested_value in value.items() + } + else: + redacted[key] = value + return redacted + + +def _mask_pii( + _logger: Any, + _method_name: str, + event_dict: dict[str, Any], +) -> dict[str, Any]: + if not _MASK_PII: + return event_dict + + level_name = event_dict.get("level", event_dict.get("log_level", "info")) + if isinstance(level_name, int): + level_name = logging.getLevelName(level_name).lower() + elif isinstance(level_name, str): + level_name = level_name.lower() + else: + level_name = "info" + + if level_name == "debug": + for key in ("username", "source_ip", "ip_address"): + value = event_dict.get(key) + if isinstance(value, str): + event_dict[key] = _mask_value(value) + return event_dict + + +def configure_logging( + level: int = logging.INFO, + mask_pii: bool = False, + json_output: bool = True, +) -> None: + """Configure structlog and stdlib logging for JSON structured output.""" + global _MASK_PII + _MASK_PII = mask_pii + + shared_processors: list[Any] = [ + structlog.contextvars.merge_contextvars, + structlog.stdlib.add_log_level, + structlog.stdlib.add_logger_name, + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.StackInfoRenderer(), + structlog.processors.format_exc_info, + _redact_secrets, + _mask_pii, + ] + + if json_output: + renderer: Any = structlog.processors.JSONRenderer() + else: + renderer = structlog.dev.ConsoleRenderer() + + structlog.configure( + processors=[ + *shared_processors, + structlog.stdlib.ProcessorFormatter.wrap_for_formatter, + ], + logger_factory=structlog.stdlib.LoggerFactory(), + wrapper_class=structlog.stdlib.BoundLogger, + cache_logger_on_first_use=True, + ) + + formatter = structlog.stdlib.ProcessorFormatter( + processor=renderer, + foreign_pre_chain=shared_processors, + ) + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(formatter) + + root_logger = logging.getLogger() + root_logger.handlers.clear() + root_logger.addHandler(handler) + root_logger.setLevel(level) + + +def get_logger(component: str) -> structlog.stdlib.BoundLogger: + """Return a logger bound with the component name.""" + return structlog.get_logger(component=component) + + +def bind_context(**kwargs: Any) -> None: + """Bind request-scoped context values for subsequent log entries.""" + structlog.contextvars.bind_contextvars(**kwargs) + + +def clear_context() -> None: + """Clear request-scoped context values.""" + structlog.contextvars.clear_contextvars() + + +def render_event_dict(event_dict: dict[str, Any]) -> str: + """Render an event dictionary as JSON for testing.""" + processed = _mask_pii(None, "", _redact_secrets(None, "", dict(event_dict))) + rendered = structlog.processors.JSONRenderer()(None, "", processed) + if isinstance(rendered, bytes): + return rendered.decode("utf-8") + return rendered + + +def parse_json_log_line(line: str) -> dict[str, Any]: + """Parse a JSON log line emitted by structlog.""" + return json.loads(line) diff --git a/hacklog/metrics.py b/hacklog/metrics.py new file mode 100644 index 0000000..ebe869b --- /dev/null +++ b/hacklog/metrics.py @@ -0,0 +1,140 @@ +"""Prometheus metrics definitions and exposition for hacklog.""" + +import os +import socket +import threading +from typing import Any + +from prometheus_client import ( + CONTENT_TYPE_LATEST, + Counter, + Gauge, + Histogram, + generate_latest, +) +from prometheus_client import start_http_server as _prometheus_start_http_server + +messages_received_total = Counter( + "messages_received_total", + "Total syslog messages received by the UDP listener.", +) + +messages_dropped_total = Counter( + "messages_dropped_total", + "Total syslog messages dropped before processing.", + ["reason"], +) + +messages_parsed_total = Counter( + "messages_parsed_total", + "Total syslog messages parsed.", + ["format", "status"], +) + +scoring_duration_seconds = Histogram( + "scoring_duration_seconds", + "Latency of anomaly score calculation in seconds.", + buckets=(0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5), +) + +scores_calculated_total = Counter( + "scores_calculated_total", + "Total anomaly scores calculated.", + ["decision"], +) + +alerts_sent_total = Counter( + "alerts_sent_total", + "Total alert notification attempts.", + ["status"], +) + +queue_depth = Gauge( + "queue_depth", + "Current syslog message queue depth.", +) + +db_operation_duration_seconds = Histogram( + "db_operation_duration_seconds", + "Database operation latency in seconds.", + ["operation"], + buckets=(0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5), +) + +_server_lock = threading.Lock() +_server_started = False +_server_port: int | None = None + + +def metrics_enabled(enabled: bool | None = None) -> bool: + """Return whether the metrics HTTP server should be enabled.""" + if enabled is not None: + return enabled + value = os.environ.get("HACKLOG_METRICS_ENABLED", "false").strip().lower() + return value in {"1", "true", "yes", "on"} + + +def metrics_port(port: int | None = None) -> int: + """Return the configured metrics HTTP port.""" + if port is not None: + return port + raw_port = os.environ.get("HACKLOG_METRICS_PORT", "9090") + return int(raw_port) + + +def render_metrics() -> bytes: + """Render all registered metrics in Prometheus exposition format.""" + return generate_latest() + + +def find_available_port() -> int: + """Find an available TCP port for the metrics HTTP server.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def start_metrics_server( + port: int | None = None, enabled: bool | None = None +) -> int | None: + """Start the Prometheus /metrics HTTP server when enabled.""" + global _server_started, _server_port + + if not metrics_enabled(enabled): + return None + + selected_port = port if port is not None else metrics_port() + + with _server_lock: + if _server_started: + return _server_port + + _prometheus_start_http_server(selected_port, addr="127.0.0.1") + _server_started = True + _server_port = selected_port + return selected_port + + +def reset_metrics_server_state_for_testing() -> None: + """Reset module-level server state between tests.""" + global _server_started, _server_port + with _server_lock: + _server_started = False + _server_port = None + + +def get_metric_objects() -> dict[str, Any]: + """Return the defined metric objects for validation and testing.""" + return { + "messages_received_total": messages_received_total, + "messages_dropped_total": messages_dropped_total, + "messages_parsed_total": messages_parsed_total, + "scoring_duration_seconds": scoring_duration_seconds, + "scores_calculated_total": scores_calculated_total, + "alerts_sent_total": alerts_sent_total, + "queue_depth": queue_depth, + "db_operation_duration_seconds": db_operation_duration_seconds, + } + + +METRICS_CONTENT_TYPE = CONTENT_TYPE_LATEST diff --git a/hacklog/parse.py b/hacklog/parse.py index 5c7442f..a65e224 100644 --- a/hacklog/parse.py +++ b/hacklog/parse.py @@ -1,94 +1,138 @@ -from entities import EventLog -from entities import enum -from datetime import datetime +"""Syslog message parser for SSH authentication events.""" + import re +from datetime import datetime -Months = enum(Jan=01, Feb=02, Mar=03, Apr=04, May=05, Jun=06, Jul=07, Oct=10, Nov=11, Dec=12) - -class Parser(): - def __init__(self, successPattern=None, failurePattern=None, testEnabled=False): - self.testEnabled = testEnabled - self.successPattern = successPattern or 'Accepted\s+publickey\s+for\s+([0-9a-zA-Z_-]+)\s+from\s+(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\s+port' - self.failurePattern = failurePattern or 'pam_unix\(sshd:auth\):\s+authentication\s+failure\;\s+login=\s+uid=0\s+euid=0\s+tty=ssh+\s+ruser=+\s+rhost=(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\s+user=([0-9a-zA-Z_-]+)' - - def parseLogLine(self, message): - returnEvent = False - if message: - - line = message.data - host = message.host - logline = re.sub('\s{2,}', ' ', line) - if "Source Network Address" not in line and "Account Name:" not in line: - logline = logline.split(' ') - if len(logline) > 5: - logline.pop(0) - log_entry = ' '.join(logline) - # successful login - m = re.match(self.successPattern, log_entry) - if m: - user_name = m.groups(0)[0] - user_ip = m.groups(0)[1] - date_time = datetime.now() - - if self.testEnabled: - date_time = m.groups(0)[3] - date_time = datetime.strptime(date_time, '%Y-%m-%d %H:%M:%S') - host = m.groups(0)[4] - - returnEvent = EventLog(date_time, user_name, user_ip, True, host) - - # login failed - m = re.match(self.failurePattern, log_entry) - if m: - user_name = m.groups(0)[1] - user_ip = m.groups(0)[0] - date_time = datetime.now() - - if self.testEnabled: - date_time = m.groups(0)[2] - date_time = datetime.strptime(date_time, '%Y-%m-%d %H:%M:%S') - host = m.groups(0)[3] - - returnEvent = EventLog(date_time, user_name, user_ip, False, host) - elif "Source Network Address" in line and "Account Name:" in line: - - logData = logline - - #form the date time and the host name from the data logs - logData = logData.split(' ') - moreData = logData.pop(0) - moreData = moreData.split(">") - moreData = moreData[1].lstrip() - day = logData.pop(0) - year = "2013" - timeFormat = logData.pop(0) - host = logData.pop(0) - date_time = year + "-" + "10" + "-" + day + " " + timeFormat - date_time = datetime.strptime(date_time, '%Y-%m-%d %H:%M:%S') - - #get source address by splitting at the string and extracting the data - userIP = logline.split("Source Network Address:") - userIP = userIP[1].lstrip() - user_ip = userIP[0:userIP.index(" ")].rstrip() - - #get account name by splitting at the string and extracting the data - accountName = logline.split("Account Name:") - if logline.count("Account Name:") > 1: - accountName = accountName[2] +from entities import EventLog, SyslogMsg + +try: + from hacklog.validators import validate_parsed_fields +except ImportError: + from validators import validate_parsed_fields + + +class Parser: + def __init__( + self, + success_pattern: str | None = None, + failure_pattern: str | None = None, + test_enabled: bool = False, + validate_fields: bool = True, + ) -> None: + self.test_enabled = test_enabled + self.validate_fields = validate_fields + self.success_pattern = ( + success_pattern + or r"Accepted\s+publickey\s+for\s+([0-9a-zA-Z_-]+)\s+from\s+" + r"(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\s+port" + ) + self.failure_pattern = ( + failure_pattern + or r"pam_unix\(sshd:auth\):\s+authentication\s+failure\;\s+login=\s+uid=0\s+" + r"euid=0\s+tty=ssh+\s+ruser=+\s+rhost=(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\s+" + r"user=([0-9a-zA-Z_-]+)" + ) + + @staticmethod + def _ssh_log_payload(data: str) -> str: + """Return the SSH message body from syslog data. + + Supports both modern UDP payloads (priority/program prefix only) and + legacy payloads that embedded the relay host as the first token. + """ + logline = re.sub(r"\s{2,}", " ", data.strip()) + parts = logline.split(" ") + if len(parts) > 1 and parts[1].startswith("<"): + parts.pop(0) + if parts and parts[0].startswith("<"): + parts.pop(0) + return " ".join(parts) + + def parse_log_line(self, message: SyslogMsg | None) -> EventLog | None: + """Parse a syslog datagram wrapped as :class:`SyslogMsg`. + + The log payload is read from ``message.data``; the originating server + hostname is taken from ``message.host`` for Linux SSH events (unless + test patterns embed HOST tokens). + """ + return_event: EventLog | None | bool = False + if message: + line = message.data + host = message.host + logline = re.sub(r"\s{2,}", " ", line) + if "Source Network Address" not in line and "Account Name:" not in line: + log_entry = self._ssh_log_payload(line) + if log_entry: + match = re.match(self.success_pattern, log_entry) + if match: + user_name = match.groups(0)[0] + user_ip = match.groups(0)[1] + date_time = datetime.now() + + if self.test_enabled: + date_time = match.groups(0)[3] + date_time = datetime.strptime( + date_time, "%Y-%m-%d %H:%M:%S" + ) + host = match.groups(0)[4] + + return_event = EventLog( + date_time, user_name, user_ip, True, host + ) + + match = re.match(self.failure_pattern, log_entry) + if match: + user_name = match.groups(0)[1] + user_ip = match.groups(0)[0] + date_time = datetime.now() + + if self.test_enabled: + date_time = match.groups(0)[2] + date_time = datetime.strptime( + date_time, "%Y-%m-%d %H:%M:%S" + ) + host = match.groups(0)[3] + + return_event = EventLog( + date_time, user_name, user_ip, False, host + ) + elif "Source Network Address" in line and "Account Name:" in line: + log_data = logline + + log_data = log_data.split(" ") + more_data = log_data.pop(0) + more_data = more_data.split(">") + more_data[1].lstrip() + day = log_data.pop(0) + year = "2013" + time_format = log_data.pop(0) + host = log_data.pop(0) + date_time = year + "-" + "10" + "-" + day + " " + time_format + date_time = datetime.strptime(date_time, "%Y-%m-%d %H:%M:%S") + + user_ip_part = logline.split("Source Network Address:") + user_ip_part = user_ip_part[1].lstrip() + user_ip = user_ip_part[0 : user_ip_part.index(" ")].rstrip() + + account_name = logline.split("Account Name:") + if logline.count("Account Name:") > 1: + account_name = account_name[2] + else: + account_name = account_name[1] + user_name_part = account_name.lstrip() + user_name = user_name_part[0 : user_name_part.index(" ")].rstrip() + return_event = EventLog(date_time, user_name, user_ip, True, host) else: - accountName = accountName[1] - userName = accountName.lstrip() - user_name = userName[0:userName.index(" ")].rstrip() - returnEvent = EventLog(date_time, user_name, user_ip, True, host) + return_event = False else: - returnEvent = False - else: - returnEvent = False - - if returnEvent: - return returnEvent - else: + return_event = False + + if return_event: + if self.validate_fields and not validate_parsed_fields( + return_event.username, + return_event.ip_address, + return_event.server, + ): + return None + return return_event return None - - - diff --git a/hacklog/readCSV.py b/hacklog/readCSV.py deleted file mode 100644 index 7a1887c..0000000 --- a/hacklog/readCSV.py +++ /dev/null @@ -1,94 +0,0 @@ -#import the modules -from time import sleep -from logging.handlers import SysLogHandler -import syslog -from datetime import datetime -import sys -import csv -import logging -import random -from server import SyslogServer -import os - -class ReadCSVFiles(object): - def __init__(self, testEnabled=False): - self.testEnabled = testEnabled - - #function that ships messages over the network - def logMessages(self, logData): - sysLogMessage = '' - logData['Date Time'] = datetime.strptime(logData['Date Time'], '%Y-%m-%d %H:%M:%S') - if self.testEnabled: - if(logData['Login_Status'] == 'TRUE' or logData['Login_Status'] == 'True'): - sysLogMessage = "sshd[%d]: Accepted publickey for %s from %s port %d ssh2 DATE_TIME %s HOST %s" %(random.randrange(1000, 9999, 345),logData['User'],logData['IP'],random.randrange(1021, 9999, 123),logData['Date Time'],logData['Server_Name']) - else: - sysLogMessage = "sshd[%d]: pam_unix(sshd:auth): authentication failure; login= uid=0 euid=0 tty=ssh ruser= rhost=%s user=%s DATE_TIME %s HOST %s" %(random.randrange(1000, 9999, 345),logData['IP'],logData['User'],logData['Date Time'],logData['Server_Name']) - else: - if(logData['Login_Status'] == 'TRUE' or logData['Login_Status'] == 'True'): - sysLogMessage = "sshd[%d]: Accepted publickey for %s from %s port %d ssh2" %(random.randrange(1000, 9999, 345),logData['User'],logData['IP'],random.randrange(1021, 9999, 123)) - else: - sysLogMessage = "sshd[%d]: pam_unix(sshd:auth): authentication failure; login= uid=0 euid=0 tty=ssh ruser= rhost=%s user=%s" %(random.randrange(1000, 9999, 345),logData['IP'],logData['User']) - - #log the message in syslogs - logger.info(sysLogMessage) - - #this function reads each log from the csv - #forms a dictionary with appropriate values - #calls a function logMessages that forms the log messages based on success or failure - def readLineGenerateLogs(self, reader): - #the outer for loop generates the headers and inner for loop associates the values to headers - rowNum = 0 - for row in reader: - eachRowData = {} - # Save header row. - if rowNum == 0: - fileData = row - - else: - colNum = 0 - for col in row: - eachRowData[fileData[colNum]] = col - colNum += 1 - if(rowNum % 5 == 0): - sleep (50.0 / 1000.0) - self.logMessages(eachRowData) - rowNum += 1 - -#main function -def main(): - - server = SyslogServer() - server.parceConfig("../conf/server.conf") - if server.testEnabled: - #initiate an object for the class - readCSV = ReadCSVFiles(server.testEnabled) - else: - readCSV = ReadCSVFiles() - - #initialize variables based on commandlines or defaults - if len(sys.argv) >= 3: - fileName = sys.argv[1] - ipAddress = sys.argv[2] - else: - fileName = "data" - ipAddress = "127.0.0.1" - - #these statements set up the syslog handler - global logger - logger = logging.getLogger() - logger.setLevel(logging.INFO) - handler = logging.handlers.SysLogHandler(address=(ipAddress, 10514)) - logger.addHandler(handler) - - #open file and generate a reader for csv files and close file - fileObject = open(fileName, "rb") - reader = csv.reader(fileObject) - - #makes call to function that generates logs - readCSV.readLineGenerateLogs(reader) - fileObject.close() - -if __name__ == "__main__": - main() - - diff --git a/hacklog/read_csv.py b/hacklog/read_csv.py new file mode 100644 index 0000000..c66ae17 --- /dev/null +++ b/hacklog/read_csv.py @@ -0,0 +1,188 @@ +"""CSV replay utility for generating syslog test traffic. + +CSV rows are replayed as syslog messages for integration testing. Date-time +fields must match ``HACKLOG_CSV_DATETIME_FORMAT`` (default ``%Y-%m-%d %H:%M:%S``). +Malformed rows are logged and skipped during batch replay. +""" + +from __future__ import annotations + +import csv +import logging +import logging.handlers +import os +import random +import sys +from datetime import datetime +from pathlib import Path +from time import sleep + +try: + from hacklog.server import SyslogServer +except ImportError: + from server import SyslogServer + +logger = logging.getLogger(__name__) + +DEFAULT_CSV_DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S" +CSV_DATETIME_FORMAT = DEFAULT_CSV_DATETIME_FORMAT +CSV_DATETIME_FORMAT_ENV = "HACKLOG_CSV_DATETIME_FORMAT" +REQUIRED_CSV_FIELDS = ("Date Time", "User", "IP", "Login_Status", "Server_Name") + + +def get_csv_datetime_format() -> str: + """Return the strptime/strftime pattern for CSV date-time fields.""" + return os.environ.get(CSV_DATETIME_FORMAT_ENV, DEFAULT_CSV_DATETIME_FORMAT) + + +def _demo_syslog_pid() -> int: + """Synthetic syslog PID for CSV replay — not used for security purposes.""" + return random.randrange(1000, 9999, 345) # NOSONAR + + +def _demo_syslog_port() -> int: + """Synthetic syslog port for CSV replay — not used for security purposes.""" + return random.randrange(1021, 9999, 123) # NOSONAR + + +def parse_csv_datetime( + raw_value: str | None, + *, + field_name: str = "Date Time", +) -> datetime: + """Parse a CSV date-time field into a timezone-naive datetime.""" + date_format = get_csv_datetime_format() + if raw_value is None: + msg = f"Invalid {field_name}: value cannot be None" + logger.error(msg) + raise ValueError(msg) + if not isinstance(raw_value, str) or not raw_value.strip(): + msg = ( + f"Invalid {field_name}: expected non-empty string in " + f"'{date_format}' format, got {raw_value!r}" + ) + logger.error(msg) + raise ValueError(msg) + try: + return datetime.strptime(raw_value.strip(), date_format) + except ValueError as exc: + msg = ( + f"Invalid {field_name}: expected format '{date_format}', " + f"got {raw_value!r}" + ) + logger.error(msg) + raise ValueError(msg) from exc + + +def format_syslog_datetime(event_time: datetime) -> str: + """Format a datetime for DATE_TIME tokens in replayed syslog messages.""" + return event_time.strftime(get_csv_datetime_format()) + + +def resolve_csv_input_path(file_name: str, base_dir: Path | None = None) -> Path: + """Resolve a CSV path and reject traversal outside the base directory.""" + base = (base_dir or Path.cwd()).resolve() + candidate = Path(file_name) + if not candidate.is_absolute(): + candidate = base / candidate + resolved = candidate.resolve() + if not resolved.is_relative_to(base): + msg = f"CSV path must stay within {base}: {file_name}" + raise ValueError(msg) + if not resolved.is_file(): + raise FileNotFoundError(f"CSV file not found: {resolved}") + return resolved + + +def _is_successful_login(login_status: str) -> bool: + return login_status.strip().upper() == "TRUE" + + +class ReadCSVFiles: + def __init__(self, test_enabled: bool = False) -> None: + self.test_enabled = test_enabled + + def log_messages(self, log_data: dict[str, str]) -> None: + missing = [field for field in REQUIRED_CSV_FIELDS if field not in log_data] + if missing: + missing_fields = ", ".join(missing) + msg = f"CSV row missing required field(s): {missing_fields}" + raise ValueError(msg) + + event_time = parse_csv_datetime(log_data["Date Time"]) + date_time_token = format_syslog_datetime(event_time) + pid = _demo_syslog_pid() + port = _demo_syslog_port() + + if _is_successful_login(log_data["Login_Status"]): + if self.test_enabled: + sys_log_message = ( + f"sshd[{pid}]: Accepted publickey for {log_data['User']} " + f"from {log_data['IP']} port {port} ssh2 " + f"DATE_TIME {date_time_token} HOST {log_data['Server_Name']}" + ) + else: + sys_log_message = ( + f"sshd[{pid}]: Accepted publickey for {log_data['User']} " + f"from {log_data['IP']} port {port} ssh2" + ) + elif self.test_enabled: + sys_log_message = ( + f"sshd[{pid}]: pam_unix(sshd:auth): authentication failure; " + f"login= uid=0 euid=0 tty=ssh ruser= rhost={log_data['IP']} " + f"user={log_data['User']} DATE_TIME {date_time_token} " + f"HOST {log_data['Server_Name']}" + ) + else: + sys_log_message = ( + f"sshd[{pid}]: pam_unix(sshd:auth): authentication failure; " + f"login= uid=0 euid=0 tty=ssh ruser= rhost={log_data['IP']} " + f"user={log_data['User']}" + ) + + logger.info(sys_log_message) + + def read_line_generate_logs(self, reader: csv.reader) -> None: + row_num = 0 + headers: list[str] = [] + for row in reader: + if row_num == 0: + headers = row + else: + each_row_data: dict[str, str] = {} + for col_num, col in enumerate(row): + each_row_data[headers[col_num]] = col + if row_num % 5 == 0: + sleep(50.0 / 1000.0) + try: + self.log_messages(each_row_data) + except ValueError as exc: + logger.error("Skipping CSV row %d: %s", row_num + 1, exc) + row_num += 1 + + +def main() -> None: + server = SyslogServer() + server.parse_config("../conf/server.conf") + read_csv = ReadCSVFiles(server.test_enabled) + + if len(sys.argv) >= 3: + file_name = sys.argv[1] + ip_address = sys.argv[2] + else: + file_name = "data" + ip_address = "127.0.0.1" + + root_logger = logging.getLogger() + root_logger.setLevel(logging.INFO) + handler = logging.handlers.SysLogHandler(address=(ip_address, 10514)) + root_logger.addHandler(handler) + + csv_path = resolve_csv_input_path(file_name) + with csv_path.open(encoding="utf-8", newline="") as file_object: + reader = csv.reader(file_object) + read_csv.read_line_generate_logs(reader) + + +if __name__ == "__main__": + main() diff --git a/hacklog/repositories.py b/hacklog/repositories.py new file mode 100644 index 0000000..f25eca7 --- /dev/null +++ b/hacklog/repositories.py @@ -0,0 +1,150 @@ +"""Repository layer for hacklog data access.""" + +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from datetime import datetime + +from entities import AuditRecord, EventLog, Profile, ProfileType, User +from logging_config import get_logger +from sqlalchemy import select +from sqlalchemy.orm import Session + +logger = get_logger("repositories") + +ProfileEntity = Profile +ProfileEntityType = ProfileType + + +class BaseRepository: + """Base repository with injected session factory and transaction helpers.""" + + def __init__(self, session_factory: Callable[[], Session]) -> None: + self._session_factory = session_factory + + @property + def session_factory(self) -> Callable[[], Session]: + return self._session_factory + + @contextmanager + def _session_scope(self) -> Iterator[Session]: + with self._session_factory() as session: + yield session + + @contextmanager + def transaction(self) -> Iterator[Session]: + """Run operations in a single transaction with rollback on failure.""" + with self._session_factory() as session: + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + + +class ProfileRepository(BaseRepository): + """CRUD for unified Profile rows keyed by profile type and username.""" + + def get_profile(self, profile_type: ProfileType, username: str) -> Profile | None: + with self._session_scope() as session: + return session.execute( + select(Profile).where( + Profile.profile_type == profile_type.value, + Profile.username == username, + ) + ).scalar_one_or_none() + + def save_profile(self, profile: Profile) -> None: + with self._session_scope() as session: + session.add(profile) + session.commit() + logger.debug( + "profile_saved", + operation="save_profile", + profile_type=profile.profile_type, + username=profile.username, + ) + + def update_profile(self, profile: Profile) -> None: + with self._session_scope() as session: + session.merge(profile) + session.commit() + logger.debug( + "profile_updated", + operation="update_profile", + profile_type=profile.profile_type, + username=profile.username, + ) + + +class UserRepository(BaseRepository): + """User entity persistence.""" + + def get_by_username(self, username: str) -> User | None: + with self._session_scope() as session: + return session.execute( + select(User).where(User.username == username) + ).scalar_one_or_none() + + def save(self, user: User) -> None: + with self._session_scope() as session: + session.add(user) + session.commit() + logger.debug( + "user_saved", + operation="save_user", + username=user.username, + ) + + def merge(self, user: User) -> None: + with self._session_scope() as session: + session.merge(user) + session.commit() + + def update_score(self, user: User, score: int) -> None: + user.score = score + with self._session_scope() as session: + session.merge(user) + session.commit() + + def update_scare_count(self, user: User) -> User: + user.scare_count += 1 + user.last_scare_date = datetime.today() + with self._session_scope() as session: + session.merge(user) + session.commit() + return user + + def reset_scare_count(self, user: User) -> None: + user.scare_count = 0 + with self._session_scope() as session: + session.merge(user) + session.commit() + + +class AuditRepository(BaseRepository): + """Append-only event log and audit record persistence.""" + + def save_event(self, event_log: EventLog) -> None: + with self._session_scope() as session: + session.add(event_log) + session.commit() + logger.debug( + "event_log_saved", + operation="save_event", + username=event_log.username, + source_ip=event_log.ip_address, + ) + + def save_audit_record(self, record: AuditRecord) -> None: + """Persist an audit record. Append-only — no update or delete operations.""" + with self._session_scope() as session: + session.add(record) + session.commit() + logger.debug( + "audit_record_saved", + operation="save_audit_record", + actor=record.actor, + action=record.action, + resource=record.resource, + ) diff --git a/hacklog/retention.py b/hacklog/retention.py new file mode 100644 index 0000000..653c5e2 --- /dev/null +++ b/hacklog/retention.py @@ -0,0 +1,270 @@ +"""Data retention service with configurable purge of old event logs and profiles.""" + +import asyncio +import time +from collections.abc import Callable +from datetime import UTC, datetime, timedelta +from typing import Any + +from sqlalchemy import delete, func, select, union_all +from sqlalchemy.orm import Session + +try: + from hacklog.entities import ( + AuditRecord, + EventLog, + Profile, + User, + ) + from hacklog.logging_config import get_logger + from hacklog.repositories import AuditRepository +except ImportError: + from entities import ( # type: ignore[no-redef] + AuditRecord, + EventLog, + Profile, + User, + ) + from logging_config import get_logger # type: ignore[no-redef] + from repositories import AuditRepository # type: ignore[no-redef] + +logger = get_logger("retention") + + +class DataRetentionService: + """Purge old event logs and inactive user profiles on a configurable schedule.""" + + def __init__( + self, + session_factory: Callable[[], Session], + audit_repository: AuditRepository | None = None, + *, + event_retention_days: int = 365, + profile_inactivity_days: int = 180, + batch_size: int = 1000, + purge_schedule_hour: int = 2, + ) -> None: + self._session_factory = session_factory + self._audit_repository = audit_repository + self._event_retention_days = event_retention_days + self._profile_inactivity_days = profile_inactivity_days + self._batch_size = batch_size + self._purge_schedule_hour = purge_schedule_hour + + # ------------------------------------------------------------------ + # Public purge methods + # ------------------------------------------------------------------ + + def purge_event_logs(self) -> int: + """Physically delete event log records older than the retention period. + + Uses batch deletes to avoid long-running SQLite transactions. + Returns the total number of records deleted. + """ + cutoff = datetime.now(UTC).replace(tzinfo=None) - timedelta( + days=self._event_retention_days + ) + start = time.monotonic() + total_deleted = 0 + + while True: + with self._session_factory() as session: + # Select a batch of old record PKs + batch_rows = session.execute( + select(EventLog.date, EventLog.username) + .where(EventLog.date < cutoff) + .limit(self._batch_size) + ).all() + + if not batch_rows: + break + + # Collect dates in this batch for a targeted DELETE + batch_dates = [row.date for row in batch_rows] + deleted = session.execute( + delete(EventLog).where(EventLog.date.in_(batch_dates)) + ).rowcount + session.commit() + total_deleted += deleted + + elapsed = time.monotonic() - start + logger.info( + "event_logs_purged", + operation="purge_event_logs", + records_deleted=total_deleted, + retention_days=self._event_retention_days, + cutoff=cutoff.isoformat(), + elapsed_seconds=round(elapsed, 3), + ) + self._emit_audit_record( + action="event_logs_purged", + outcome=str(total_deleted), + details={ + "records_deleted": total_deleted, + "retention_days": self._event_retention_days, + "cutoff": cutoff.isoformat(), + "elapsed_seconds": round(elapsed, 3), + }, + ) + return total_deleted + + def purge_inactive_profiles(self) -> int: + """Physically delete user profiles for users inactive beyond the threshold. + + Inactivity is measured as max(date) across all profile tables and EventLog. + Returns the total number of users purged. + """ + cutoff = datetime.now(UTC).replace(tzinfo=None) - timedelta( + days=self._profile_inactivity_days + ) + start = time.monotonic() + total_purged = 0 + + while True: + inactive_usernames = self._find_inactive_usernames(cutoff) + if not inactive_usernames: + break + + for username in inactive_usernames: + self._delete_user_records(username) + total_purged += 1 + + elapsed = time.monotonic() - start + logger.info( + "inactive_profiles_purged", + operation="purge_inactive_profiles", + users_purged=total_purged, + inactivity_days=self._profile_inactivity_days, + cutoff=cutoff.isoformat(), + elapsed_seconds=round(elapsed, 3), + ) + self._emit_audit_record( + action="inactive_profiles_purged", + outcome=str(total_purged), + details={ + "users_purged": total_purged, + "inactivity_days": self._profile_inactivity_days, + "cutoff": cutoff.isoformat(), + "elapsed_seconds": round(elapsed, 3), + }, + ) + return total_purged + + def run_purge(self) -> dict[str, Any]: + """Run both event log and profile purges; return a summary dict.""" + start = time.monotonic() + event_logs_deleted = self.purge_event_logs() + users_purged = self.purge_inactive_profiles() + elapsed = time.monotonic() - start + summary = { + "event_logs_deleted": event_logs_deleted, + "users_purged": users_purged, + "elapsed_seconds": round(elapsed, 3), + "run_at": datetime.now(UTC).isoformat(), + } + logger.info("purge_complete", operation="run_purge", **summary) + return summary + + # ------------------------------------------------------------------ + # Async scheduler + # ------------------------------------------------------------------ + + async def schedule_daily_purge(self) -> None: + """Run purge daily at the configured UTC hour; runs indefinitely.""" + logger.info( + "purge_scheduler_started", + operation="schedule_daily_purge", + schedule_hour_utc=self._purge_schedule_hour, + ) + while True: + now = datetime.now(UTC) + next_run = now.replace( + hour=self._purge_schedule_hour, + minute=0, + second=0, + microsecond=0, + ) + if next_run <= now: + next_run += timedelta(days=1) + wait_seconds = (next_run - now).total_seconds() + logger.info( + "purge_scheduled", + operation="schedule_daily_purge", + next_run_utc=next_run.isoformat(), + wait_seconds=round(wait_seconds, 1), + ) + await asyncio.sleep(wait_seconds) + try: + await asyncio.to_thread(self.run_purge) + except Exception: + logger.exception( + "purge_error", + operation="schedule_daily_purge", + ) + + # ------------------------------------------------------------------ + # Private helpers + # ------------------------------------------------------------------ + + def _find_inactive_usernames(self, cutoff: datetime) -> list[str]: + """Return up to batch_size usernames whose last activity is before cutoff.""" + with self._session_factory() as session: + # Union of dates across all activity sources + all_activity = union_all( + select( + EventLog.username.label("username"), EventLog.date.label("date") + ), + select(Profile.username.label("username"), Profile.date.label("date")), + ).subquery("all_activity") + + inactive_q = ( + select(all_activity.c.username) + .group_by(all_activity.c.username) + .having(func.max(all_activity.c.date) < cutoff) + .limit(self._batch_size) + ) + return list(session.execute(inactive_q).scalars().all()) + + def _delete_user_records(self, username: str) -> None: + """Delete all records for a username across profiles, events, and users.""" + with self._session_factory() as session: + session.execute(delete(Profile).where(Profile.username == username)) + session.execute(delete(EventLog).where(EventLog.username == username)) + session.execute(delete(User).where(User.username == username)) + session.commit() + logger.debug( + "user_records_deleted", + operation="delete_user_records", + username=username, + ) + + def _emit_audit_record( + self, + action: str, + outcome: str, + details: dict[str, Any], + ) -> None: + """Emit a structured log audit entry and optionally persist to DB.""" + timestamp = datetime.now(UTC) + logger.info( + "audit_event", + audit=True, + actor="system", + action=action, + source_ip=None, + resource="database", + outcome=outcome, + details=details, + timestamp=timestamp.isoformat(), + ) + if self._audit_repository is not None: + record = AuditRecord( + timestamp=timestamp, + actor="system", + source_ip=None, + resource="database", + action=action, + outcome=outcome, + details=details, + ) + self._audit_repository.save_audit_record(record) diff --git a/hacklog/run.sh b/hacklog/run.sh deleted file mode 100755 index 7f74e83..0000000 --- a/hacklog/run.sh +++ /dev/null @@ -1,2 +0,0 @@ -#/bin/sh -python server.py -c ../conf/server.conf diff --git a/hacklog/scoring.py b/hacklog/scoring.py new file mode 100644 index 0000000..b421966 --- /dev/null +++ b/hacklog/scoring.py @@ -0,0 +1,232 @@ +"""Scoring engine with injected update and alert services.""" + +import math +from datetime import UTC, date, datetime +from typing import Any + +from alerting import AlertService +from entities import AuditRecord, EventLog, IpLocation, Threshold, User, Weight +from logging_config import get_logger +from repositories import AuditRepository +from services import UpdateService + +logger = get_logger("scoring") + + +class ScoringEngine: + """Score authentication events and trigger alerts using injected services.""" + + def __init__( + self, + update_service: UpdateService, + alert_service: AlertService, + audit_repository: AuditRepository | None = None, + ) -> None: + self._update_service = update_service + self._alert_service = alert_service + self._audit_repository = audit_repository + + def _emit_audit_record( + self, + actor: str, + action: str, + *, + source_ip: str | None = None, + resource: str | None = None, + outcome: str | None = None, + details: dict[str, Any] | None = None, + ) -> None: + """Emit an audit event as a structured log entry and optionally persist it.""" + timestamp = datetime.now(UTC) + logger.info( + "audit_event", + audit=True, + actor=actor, + action=action, + source_ip=source_ip, + resource=resource, + outcome=outcome, + details=details, + timestamp=timestamp.isoformat(), + ) + if self._audit_repository is not None: + record = AuditRecord( + timestamp=timestamp, + actor=actor, + source_ip=source_ip, + resource=resource, + action=action, + outcome=outcome, + details=details, + ) + self._audit_repository.save_audit_record(record) + + def process_event_log(self, event_log: EventLog) -> None: + self.audit_event_log(event_log) + score, dimension_scores = self.calculate_new_score(event_log) + user = self._update_service.fetch_user(event_log) + time_diff = event_log.date - user.last_scare_date + self._update_service.update_user_score(user, score) + if score > Threshold.CRITICAL: + self._emit_audit_record( + actor=event_log.username, + action="score_calculated", + source_ip=event_log.ip_address, + resource=event_log.server, + outcome=str(score), + details={**dimension_scores, "alert_decision": "alert_triggered"}, + ) + self.process_alert(user, event_log) + elif score > Threshold.SCARY: + if user.scare_count >= Threshold.SCARECOUNT: + self._emit_audit_record( + actor=event_log.username, + action="score_calculated", + source_ip=event_log.ip_address, + resource=event_log.server, + outcome=str(score), + details={**dimension_scores, "alert_decision": "alert_triggered"}, + ) + self.process_alert(user, event_log) + else: + self._emit_audit_record( + actor=event_log.username, + action="score_calculated", + source_ip=event_log.ip_address, + resource=event_log.server, + outcome=str(score), + details={**dimension_scores, "alert_decision": "scare_accumulated"}, + ) + user = self._update_service.update_user_scare_count(user) + self._emit_audit_record( + actor=event_log.username, + action="scare_count_updated", + source_ip=event_log.ip_address, + resource=event_log.server, + outcome=str(user.scare_count), + ) + elif abs(time_diff.days) >= Threshold.SCAREDATEEXPIRE: + self._emit_audit_record( + actor=event_log.username, + action="score_calculated", + source_ip=event_log.ip_address, + resource=event_log.server, + outcome=str(score), + details={**dimension_scores, "alert_decision": "none"}, + ) + self._update_service.reset_user_scare_count(user) + self._emit_audit_record( + actor=event_log.username, + action="scare_count_reset", + source_ip=event_log.ip_address, + resource=event_log.server, + outcome="0", + ) + else: + self._emit_audit_record( + actor=event_log.username, + action="score_calculated", + source_ip=event_log.ip_address, + resource=event_log.server, + outcome=str(score), + details={**dimension_scores, "alert_decision": "none"}, + ) + + def calculate_new_score(self, event_log: EventLog) -> tuple[int, dict[str, float]]: + """Calculate the risk score and return (total_score, dimension_scores).""" + success_score = self.calculate_success_score(event_log.success) + ip_location_score = self.calculate_ip_location_score(event_log.ip_address) + server_score = self.calculate_server_score(event_log) + ip_score = self.calculate_ip_score(event_log) + day_score = self.calculate_days_score(event_log) + hour_score = self.calculate_hours_score(event_log) + total_score = ( + success_score + + ip_location_score + + server_score + + ip_score + + day_score + + hour_score + ) + dimension_scores: dict[str, float] = { + "success_score": float(success_score), + "ip_location_score": float(ip_location_score), + "server_score": float(server_score), + "ip_score": float(ip_score), + "day_score": float(day_score), + "hour_score": float(hour_score), + "total_score": float(total_score), + } + logger.debug( + "score_calculated", + operation="calculate_score", + username=event_log.username, + source_ip=event_log.ip_address, + score=total_score, + ) + return int(total_score), dimension_scores + + def audit_event_log(self, event_log: EventLog) -> None: + self._update_service.audit_event_log(event_log) + + def process_alert(self, user: User, event_log: EventLog) -> None: + logger.info( + "alert_triggered", + operation="process_alert", + username=user.username, + source_ip=event_log.ip_address, + score=user.score, + server=event_log.server, + ) + self._alert_service.send_email_alert(user, event_log) + + def calculate_hours_score(self, event_log: EventLog) -> float: + hour_freq = self._update_service.update_and_return_hour_freq_for_user(event_log) + return self.calculate_subscore(hour_freq) * Weight.HOURS + + def calculate_days_score(self, event_log: EventLog) -> float: + day_freq = self._update_service.update_and_return_day_freq_for_user(event_log) + return self.calculate_subscore(day_freq) * Weight.DAYS + + def calculate_server_score(self, event_log: EventLog) -> float: + server_freq = self._update_service.update_and_return_server_freq_for_user( + event_log + ) + return self.calculate_subscore(server_freq) * Weight.SERVER + + def calculate_ip_score(self, event_log: EventLog) -> float: + ip_freq = self._update_service.update_and_return_ip_freq_for_user(event_log) + return self.calculate_subscore(ip_freq) * Weight.IP + + @staticmethod + def calculate_subscore(freq: float) -> float: + subscore = math.log(freq, 2) + subscore = subscore * -10 + if subscore > 100: + return 1.0 + return float(subscore) / 100 + + @staticmethod + def calculate_success_score(success: bool) -> int: + success_score = Weight.SUCCESS + if success: + success_score = 0 + return int(success_score) + + @staticmethod + def calculate_ip_location_score(ip_address: str) -> int: + ip_score = Weight.EXT + if IpLocation.check_ip_for_vpn(ip_address): + ip_score = Weight.VPN + if IpLocation.check_ip_for_internal(ip_address): + ip_score = Weight.INT + return int(ip_score) + + +def smoke_test_process( + update_service: UpdateService, alert_service: AlertService +) -> None: + """Exercise scoring with injected services (development helper).""" + engine = ScoringEngine(update_service, alert_service) + event_log = EventLog(date.today(), "nrhine", "127.0.0.1", True, "ae1-app80-prd") + engine.process_event_log(event_log) diff --git a/hacklog/security.py b/hacklog/security.py new file mode 100644 index 0000000..577e66f --- /dev/null +++ b/hacklog/security.py @@ -0,0 +1,173 @@ +"""Network-layer syslog ingestion security controls.""" + +import ipaddress +import os +import threading +import time +from dataclasses import dataclass + +try: + from hacklog.logging_config import get_logger + from hacklog.metrics import messages_dropped_total, messages_received_total +except ImportError: + from logging_config import get_logger + from metrics import messages_dropped_total, messages_received_total + +logger = get_logger("security") + + +@dataclass(frozen=True) +class ValidationResult: + """Outcome of validating an incoming syslog datagram.""" + + accepted: bool + reason: str | None = None + + +def parse_allowed_cidrs(raw_value: str | None) -> list[str]: + """Parse comma-separated CIDR values from configuration.""" + if not raw_value or not raw_value.strip(): + return [] + return [entry.strip() for entry in raw_value.split(",") if entry.strip()] + + +def allowed_cidrs_from_env() -> list[str]: + """Load allowlisted CIDRs from HACKLOG_ALLOWED_CIDRS.""" + return parse_allowed_cidrs(os.environ.get("HACKLOG_ALLOWED_CIDRS")) + + +class IpAllowlist: + """CIDR-based source IP allowlist.""" + + def __init__(self, cidrs: list[str] | None = None) -> None: + self._networks: list[ipaddress.IPv4Network | ipaddress.IPv6Network] = [] + for cidr in cidrs or []: + self._networks.append(ipaddress.ip_network(cidr, strict=False)) + + def is_allowed(self, source_ip: str) -> bool: + if not self._networks: + return True + try: + address = ipaddress.ip_address(source_ip) + except ValueError: + return False + return any(address in network for network in self._networks) + + +class TokenBucket: + """Token bucket used for per-source rate limiting.""" + + def __init__(self, rate_per_second: float, burst_capacity: int) -> None: + self.rate_per_second = rate_per_second + self.burst_capacity = burst_capacity + self.tokens = float(burst_capacity) + self.last_refill = time.monotonic() + + def consume(self, amount: int = 1) -> bool: + now = time.monotonic() + elapsed = now - self.last_refill + self.tokens = min( + self.burst_capacity, self.tokens + elapsed * self.rate_per_second + ) + self.last_refill = now + if self.tokens >= amount: + self.tokens -= amount + return True + return False + + +class RateLimiter: + """Thread-safe per-source token bucket rate limiter with TTL cleanup.""" + + def __init__( + self, + rate_per_second: float, + burst_capacity: int | None = None, + ttl_seconds: float = 300.0, + ) -> None: + self.rate_per_second = rate_per_second + self.burst_capacity = ( + burst_capacity if burst_capacity is not None else int(rate_per_second) + ) + self.ttl_seconds = ttl_seconds + self._buckets: dict[str, tuple[TokenBucket, float]] = {} + self._lock = threading.Lock() + + def allow(self, source_ip: str) -> bool: + now = time.monotonic() + with self._lock: + self._cleanup_expired(now) + bucket, _last_seen = self._buckets.get(source_ip, (None, now)) + if bucket is None: + bucket = TokenBucket(self.rate_per_second, self.burst_capacity) + allowed = bucket.consume() + self._buckets[source_ip] = (bucket, now) + return allowed + + def _cleanup_expired(self, now: float) -> None: + expired = [ + source_ip + for source_ip, (_, last_seen) in self._buckets.items() + if now - last_seen > self.ttl_seconds + ] + for source_ip in expired: + del self._buckets[source_ip] + + +class MessageValidator: + """Validate syslog datagrams before they enter the processing queue.""" + + def __init__( + self, + allowlist: IpAllowlist, + max_message_size: int, + rate_limiter: RateLimiter, + meter_and_log: bool = True, + ) -> None: + self.allowlist = allowlist + self.max_message_size = max_message_size + self.rate_limiter = rate_limiter + self.meter_and_log = meter_and_log + + def validate(self, source_ip: str, payload: bytes) -> ValidationResult: + if not self.allowlist.is_allowed(source_ip): + return self._reject(source_ip, "ip_rejected", len(payload)) + if len(payload) > self.max_message_size: + return self._reject(source_ip, "oversized", len(payload)) + if not self.rate_limiter.allow(source_ip): + return self._reject(source_ip, "rate_limited", len(payload)) + + if self.meter_and_log: + messages_received_total.inc() + return ValidationResult(accepted=True) + + def _reject( + self, source_ip: str, reason: str, message_size: int + ) -> ValidationResult: + if self.meter_and_log: + messages_dropped_total.labels(reason=reason).inc() + logger.warning( + "message_dropped", + operation="validate_datagram", + source_ip=source_ip, + reason=reason, + message_size=message_size, + ) + return ValidationResult(accepted=False, reason=reason) + + +def build_message_validator( + allowed_cidrs: list[str] | None = None, + max_message_size: int = 2048, + rate_per_second: float = 100.0, + burst_capacity: int | None = None, + meter_and_log: bool = True, +) -> MessageValidator: + """Construct a MessageValidator from syslog security settings.""" + cidrs = allowed_cidrs if allowed_cidrs is not None else allowed_cidrs_from_env() + return MessageValidator( + allowlist=IpAllowlist(cidrs), + max_message_size=max_message_size, + rate_limiter=RateLimiter(rate_per_second, burst_capacity=burst_capacity), + meter_and_log=meter_and_log, + ) diff --git a/hacklog/server.py b/hacklog/server.py old mode 100755 new mode 100644 index 2f48acc..3696c8b --- a/hacklog/server.py +++ b/hacklog/server.py @@ -1,126 +1,129 @@ -import sys -import time -import thread -import random -import algorithm -import signal -import logging - -from twisted.internet.protocol import DatagramProtocol -from twisted.internet import reactor, defer +"""Hacklog syslog server entrypoint.""" +import asyncio +import configparser from optparse import OptionParser -from ConfigParser import ConfigParser -from parse import Parser -from entities import SyslogMsg, MailConf -from Queue import Queue -from entities import create_tables, create_db_engine - -queue = Queue() - -class SyslogServer(): - """ - Syslog server based on twisted library - """ - def __init__(self): - self.dbFile = 'hacklog.db' - self.port = 10514 - self.bind_address = '127.0.0.1' - self.config_file = '../conf/server.conf' - self.loglevel = logging.DEBUG - self.running = True - self.usage = "usage: %prog -c config_file" - self.testEnabled = False - self.emailTest = False - self.successPattern = None - self.failurePattern = None - - def parceConfig(self, config_file): - config = ConfigParser() - config.read(config_file) - - if config.has_option('SyslogServer', 'bind_address'): - self.bind_address = config.get('SyslogServer', 'bind_address') - if config.has_option('SyslogServer', 'bind_port'): - self.port = config.getint('SyslogServer', 'port') - if config.has_option('SyslogServer', 'db_file'): - self.dfFile = config.get('SyslogServer', 'df_file') - if config.has_option('MailServer', 'gmail_test'): - self.emailTest = config.getboolean('MailServer', 'gmail_test') - if config.has_option('Parse', 'test_enabled'): - self.testEnabled = config.getboolean('Parse', 'test_enabled') - if config.has_option('Parse', 'success_pattern'): - self.successPattern = config.get('Parse', 'success_pattern') - if config.has_option('Parse', 'failure_pattern'): - self.failurePattern = config.get('Parse', 'failure_pattern') - - def readCmdArgs(self): - cmdParser = OptionParser(usage=self.usage) - cmdParser.add_option("-c", "--config", dest="config_file", - help="configuration file", metavar="FILE") - (options, args) = cmdParser.parse_args() - if options.config_file: - self.config_file = options.config_file - - def setLogging(self): - logging.basicConfig(level=self.loglevel) - - - def interrupt(self, signum, stackframe): - logging.debug("Got signal: %s" % signum) - self.running = False - queue.put(SyslogMsg()) - self.stop() - - def messageParcer(self): - logging.debug("messageParcer in thread " + str(thread.get_ident())) - parser = None - # get parsing patterns from config file when in testing mode - if self.testEnabled: - parser = Parser(self.successPattern, self.failurePattern, self.testEnabled) - else: - parser = Parser() - - while self.running: - msg = queue.get() - eventLog = parser.parseLogLine(msg) - if eventLog: - algorithm.processEventLog(eventLog) - logging.debug("messages in queue " + str(queue.qsize()) + ", received %r from %s:%d" % (msg.data, msg.host, msg.port)) - - def cleanupThread(self): - threadPool = reactor.getThreadPool() - threadPool.stop() - - def run(self): - signal.signal(signal.SIGINT, self.interrupt) - reactor.callInThread(self.messageParcer) - reactor.listenUDP(self.port, SyslogReader()) - reactor.run() - - def start(self): - self.readCmdArgs() - self.parceConfig(self.config_file) - self.setLogging() - algorithm.setServices(MailConf(self.emailTest)) - create_db_engine(self) - create_tables() - self.run() - - def stop(self): - reactor.stop() - - -class SyslogReader(DatagramProtocol): - - def datagramReceived(self, data, (host, port)): - syslogMsg = SyslogMsg(data, host, port) - queue.put(syslogMsg) - -def main(): +from alerting import AlertService +from config import load_config_or_exit +from entities import create_db_engine, create_tables +from logging_config import configure_logging, get_logger +from parse import Parser +from scoring import ScoringEngine +from services import UpdateService +from session import Session +from syslog_server import DEFAULT_QUEUE_MAXSIZE, run_async_syslog_server + +logger = get_logger("server") + + +class SyslogServer: + """Syslog server orchestrating config, parsing, and asyncio UDP ingestion.""" + + def __init__(self) -> None: + self.db_file = "hacklog.db" + self.port = 10514 + self.bind_address = "127.0.0.1" + self.config_file = "../conf/server.conf" + self.loglevel = 10 + self.usage = "usage: %prog -c config_file" + self.test_enabled = False + self.email_test = False + self.success_pattern: str | None = None + self.failure_pattern: str | None = None + self.message_queue: asyncio.Queue = asyncio.Queue(maxsize=DEFAULT_QUEUE_MAXSIZE) + self.scoring_engine: ScoringEngine | None = None + self.db_engine = None + + def parse_config(self, config_file: str) -> None: + config = configparser.ConfigParser(interpolation=None) + config.read(config_file) + + if config.has_option("SyslogServer", "bind_address"): + self.bind_address = config.get("SyslogServer", "bind_address") + if config.has_option("SyslogServer", "bind_port"): + self.port = config.getint("SyslogServer", "port") + if config.has_option("SyslogServer", "db_file"): + self.db_file = config.get("SyslogServer", "db_file") + if config.has_option("MailServer", "gmail_test"): + self.email_test = config.getboolean("MailServer", "gmail_test") + if config.has_option("Parse", "test_enabled"): + self.test_enabled = config.getboolean("Parse", "test_enabled") + if config.has_option("Parse", "success_pattern"): + self.success_pattern = config.get("Parse", "success_pattern") + if config.has_option("Parse", "failure_pattern"): + self.failure_pattern = config.get("Parse", "failure_pattern") + + def read_cmd_args(self) -> None: + cmd_parser = OptionParser(usage=self.usage) + cmd_parser.add_option( + "-c", + "--config", + dest="config_file", + help="configuration file", + metavar="FILE", + ) + options, _args = cmd_parser.parse_args() + if options.config_file: + self.config_file = options.config_file + + def set_logging(self) -> None: + configure_logging(level=self.loglevel) + + def _build_parser(self) -> Parser: + if self.test_enabled: + return Parser(self.success_pattern, self.failure_pattern, self.test_enabled) + return Parser() + + def _release_resources(self) -> None: + if self.db_engine is not None: + self.db_engine.dispose() + self.db_engine = None + logger.info("server_resources_released", operation="shutdown") + + def run(self) -> None: + if self.scoring_engine is None: + raise RuntimeError("ScoringEngine must be wired before run()") + + app_config = load_config_or_exit() + syslog = app_config.syslog + bind_address = self.bind_address or syslog.bind_address + port = self.port or syslog.port + parser = self._build_parser() + + try: + asyncio.run( + run_async_syslog_server( + bind_address=bind_address, + port=port, + parser=parser, + process_event=self.scoring_engine.process_event_log, + syslog_config=syslog, + queue=self.message_queue, + on_shutdown=self._release_resources, + ) + ) + finally: + self._release_resources() + + def start(self) -> None: + self.read_cmd_args() + self.parse_config(self.config_file) + self.set_logging() + app_config = load_config_or_exit() + self.db_engine = create_db_engine(self) + create_tables(self.db_engine) + Session.configure(bind=self.db_engine) + update_service = UpdateService() + alert_service = AlertService(app_config.smtp) + self.scoring_engine = ScoringEngine(update_service, alert_service) + self.run() + + +def main() -> None: server = SyslogServer() server.start() -if __name__ == '__main__': + +if __name__ == "__main__": main() diff --git a/hacklog/services.py b/hacklog/services.py index 629fc20..c43654a 100644 --- a/hacklog/services.py +++ b/hacklog/services.py @@ -1,135 +1,147 @@ -from accessdata import * -from datetime import datetime -import smtplib -from entities import * -import server +"""Profile update services.""" -from email.mime.multipart import MIMEMultipart -from email.mime.text import MIMEText +from collections.abc import Callable -HourRangeEnum = enum(EARLY=range(4), DAWN=range(4,8), MORNING=range(8,12), AFTERNOON=range(12,16), EVE=range(16,20), NIGHT=range(20,24)) +from entities import EventLog, Profile, ProfileType, User +from logging_config import get_logger +from repositories import AuditRepository, ProfileRepository, UserRepository +from session import Session as SessionFactory +from sqlalchemy.orm import Session -class EmailService: +logger = get_logger("services") - def __init__(self, conf=None): - # FIXME: this needs to be rewritten, so config comes from config file - # and no actions are done in the constructor itself - if conf.emailTest: - gmailUser = 'sshAlertsTest@gmail.com' - gmailPassword = 'Dandb@123' - self.mailServer = smtplib.SMTP('smtp.gmail.com', 587) - self.fromAddress = gmailUser - self.mailServer.ehlo() - self.mailServer.starttls() - self.mailServer.ehlo() - self.mailServer.login(gmailUser, gmailPassword) - else: - self.mailServer = smtplib.SMTP() - self.fromAddress = 'sshAlerts@dandb.com' - def sendMail(self, toAddress, msg): - msg['From'] = self.fromAddress - self.mailServer.connect() - self.mailServer.sendmail(self.fromAddress, toAddress, msg.as_string()) - - def sendEmailAlert(self, user, eventLog): - fromAddress = 'sshAlerts@dandb.com' - toAddress = 'hackloggroup@googlegroups.com' - - # Create message container - the correct MIME type is multipart/alternative. - msg = MIMEMultipart() - msg['Subject'] = "EMAIL ALERT - CONCERNING SSH ACTIVITY ON: " + eventLog.server - msg['To'] = toAddress - - text = "Hi!\nHow are you?\nThere was some suspicious activity on the following server: " + eventLog.server + " for user: " + user.username + "\n Their current score is " + str(user.score) - - # Record the MIME types of both parts - text/plain and text/html. - part = MIMEText(text, 'plain') - - msg.attach(part) - - self.sendMail(toAddress, msg) +class HourRangeEnum: + EARLY = range(4) + DAWN = range(4, 8) + MORNING = range(8, 12) + AFTERNOON = range(12, 16) + EVE = range(16, 20) + NIGHT = range(20, 24) class UpdateService: - - def __init__(self, conf=None): - self._hourRanges = [HourRangeEnum.EARLY, HourRangeEnum.DAWN, HourRangeEnum.MORNING, HourRangeEnum.AFTERNOON, HourRangeEnum.EVE, HourRangeEnum.NIGHT] - self._rangeName = ['early', 'dawn', 'morning', 'afternoon', 'eve', 'night'] - self._genericDao = GenericDao() - self._serverDao = ServerDao() - self._hoursDao = HoursDao() - self._daysDao = DaysDao() - self._ipAddressDao = IpAddressDao() - self._userDao = UserDao() - - def updateAndReturnFreqForProfile(self, profile, value): - profileDict = profile.profile - profileDict[value] = profileDict.get(value,0) + 1 - profile.totalCount+=1 - freq = float(profileDict[value])/profile.totalCount - profile.profile = profileDict - self._genericDao.mergeEntity(profile) - return freq - - def updateAndReturnHourFreqForUser(self, eventLog): - hourProfile = self._hoursDao.getProfileByUser(eventLog.username) - hour = eventLog.date.hour - rangeName = self._rangeName[0] - for hourRange in self._hourRanges: - if hour in hourRange: - rangeName = self._rangeName[self._hourRanges.index(hourRange)] - break - if hourProfile == None: - hourProfile = Hours(eventLog.date, eventLog.username, {}, 0) - self._genericDao.saveEntity(hourProfile) - hourFreq = self.updateAndReturnFreqForProfile(hourProfile, rangeName) - return hourFreq - - def updateAndReturnDayFreqForUser(self, eventLog): - dayProfile = self._daysDao.getProfileByUser(eventLog.username) - day = eventLog.date.strftime('%a') - if dayProfile == None: - dayProfile = Days(eventLog.date, eventLog.username, {}, 0) - self._genericDao.saveEntity(dayProfile) - dayFreq = self.updateAndReturnFreqForProfile(dayProfile, day) - return dayFreq - - def updateAndReturnServerFreqForUser(self, eventLog): - serverProfile = self._serverDao.getProfileByUser(eventLog.username) - if serverProfile == None: - serverProfile = Servers(eventLog.date, eventLog.username, {}, 0) - self._genericDao.saveEntity(serverProfile) - serverFreq = self.updateAndReturnFreqForProfile(serverProfile, eventLog.server) - return serverFreq - - def updateAndReturnIpFreqForUser(self, eventLog): - ipProfile = self._ipAddressDao.getProfileByUser(eventLog.username) - if ipProfile == None: - ipProfile = IpAddress(eventLog.date, eventLog.username, {}, 0) - self._genericDao.saveEntity(ipProfile) - ipFreq = self.updateAndReturnFreqForProfile(ipProfile, eventLog.ipAddress) - return ipFreq - - def auditEventLog(self, eventLog): - self._genericDao.saveEntity(eventLog) - - def fetchUser(self, eventLog): - user = self._userDao.getUserByName(eventLog.username) - if user == None: - user = User(eventLog.username, eventLog.date, 0) - self._genericDao.saveEntity(user) - return user - - def updateUserScareCount(self, user): - user.scareCount += 1 - user.lastScareDate = datetime.today() - self._genericDao.mergeEntity(user) - - def updateUserScore (self, user, score): - user.score = score - self._genericDao.mergeEntity(user) - - def resetUserScareCount(self, user): - user.scareCount = 0 - self._genericDao.mergeEntity(user) + def __init__( + self, + conf: object | None = None, + *, + session_factory: Callable[[], Session] | None = None, + profile_repository: ProfileRepository | None = None, + user_repository: UserRepository | None = None, + audit_repository: AuditRepository | None = None, + ) -> None: + del conf + factory = session_factory or SessionFactory + self._profile_repository = profile_repository or ProfileRepository(factory) + self._user_repository = user_repository or UserRepository(factory) + self._audit_repository = audit_repository or AuditRepository(factory) + self._hour_ranges = [ + HourRangeEnum.EARLY, + HourRangeEnum.DAWN, + HourRangeEnum.MORNING, + HourRangeEnum.AFTERNOON, + HourRangeEnum.EVE, + HourRangeEnum.NIGHT, + ] + self._range_name = ["early", "dawn", "morning", "afternoon", "eve", "night"] + + def update_and_return_freq_for_profile(self, profile: Profile, value: str) -> float: + profile_dict = profile.profile + profile_dict[value] = profile_dict.get(value, 0) + 1 + profile.total_count += 1 + freq = float(profile_dict[value]) / profile.total_count + profile.profile = profile_dict + self._profile_repository.update_profile(profile) + logger.debug( + "profile_frequency_updated", + operation="update_profile_frequency", + profile_type=profile.profile_type, + value=value, + frequency=freq, + ) + return freq + + def update_and_return_hour_freq_for_user(self, event_log: EventLog) -> float: + hour_profile = self._profile_repository.get_profile( + ProfileType.HOURS, event_log.username + ) + hour = event_log.date.hour + range_name = self._range_name[0] + for hour_range in self._hour_ranges: + if hour in hour_range: + range_name = self._range_name[self._hour_ranges.index(hour_range)] + break + if hour_profile is None: + hour_profile = Profile( + event_log.date, event_log.username, ProfileType.HOURS, {}, 0 + ) + self._profile_repository.save_profile(hour_profile) + hour_freq = self.update_and_return_freq_for_profile(hour_profile, range_name) + return hour_freq + + def update_and_return_day_freq_for_user(self, event_log: EventLog) -> float: + day_profile = self._profile_repository.get_profile( + ProfileType.DAYS, event_log.username + ) + day = event_log.date.strftime("%a") + if day_profile is None: + day_profile = Profile( + event_log.date, event_log.username, ProfileType.DAYS, {}, 0 + ) + self._profile_repository.save_profile(day_profile) + day_freq = self.update_and_return_freq_for_profile(day_profile, day) + return day_freq + + def update_and_return_server_freq_for_user(self, event_log: EventLog) -> float: + server_profile = self._profile_repository.get_profile( + ProfileType.SERVER, event_log.username + ) + if server_profile is None: + server_profile = Profile( + event_log.date, event_log.username, ProfileType.SERVER, {}, 0 + ) + self._profile_repository.save_profile(server_profile) + server_freq = self.update_and_return_freq_for_profile( + server_profile, event_log.server + ) + return server_freq + + def update_and_return_ip_freq_for_user(self, event_log: EventLog) -> float: + ip_profile = self._profile_repository.get_profile( + ProfileType.IP_ADDRESS, event_log.username + ) + if ip_profile is None: + ip_profile = Profile( + event_log.date, event_log.username, ProfileType.IP_ADDRESS, {}, 0 + ) + self._profile_repository.save_profile(ip_profile) + ip_freq = self.update_and_return_freq_for_profile( + ip_profile, event_log.ip_address + ) + return ip_freq + + def audit_event_log(self, event_log: EventLog) -> None: + self._audit_repository.save_event(event_log) + logger.debug( + "event_log_audited", + operation="audit_event_log", + username=event_log.username, + source_ip=event_log.ip_address, + server=event_log.server, + ) + + def fetch_user(self, event_log: EventLog) -> User: + user = self._user_repository.get_by_username(event_log.username) + if user is None: + user = User(event_log.username, event_log.date, 0) + self._user_repository.save(user) + return user + + def update_user_scare_count(self, user: User) -> User: + return self._user_repository.update_scare_count(user) + + def update_user_score(self, user: User, score: int) -> None: + self._user_repository.update_score(user, score) + + def reset_user_scare_count(self, user: User) -> None: + self._user_repository.reset_scare_count(user) diff --git a/hacklog/session.py b/hacklog/session.py index 8fb28d3..0da5ab9 100644 --- a/hacklog/session.py +++ b/hacklog/session.py @@ -1,3 +1,5 @@ +"""SQLAlchemy session factory for hacklog.""" + from sqlalchemy.orm import sessionmaker -Session = sessionmaker() +Session = sessionmaker(autoflush=True, autocommit=False, expire_on_commit=False) diff --git a/hacklog/stop.sh b/hacklog/stop.sh deleted file mode 100755 index a375488..0000000 --- a/hacklog/stop.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh -pid=$(ps aux | grep server.py | grep -v grep | awk '{ print $2}') -kill -HUP $pid diff --git a/hacklog/syslog_server.py b/hacklog/syslog_server.py new file mode 100644 index 0000000..901ca5c --- /dev/null +++ b/hacklog/syslog_server.py @@ -0,0 +1,249 @@ +"""Asyncio UDP syslog listener and message consumer.""" + +from __future__ import annotations + +import asyncio +import os +import signal +from collections.abc import Callable +from typing import TYPE_CHECKING + +try: + from hacklog.config import SyslogConfig + from hacklog.entities import SyslogMsg + from hacklog.logging_config import get_logger + from hacklog.metrics import messages_dropped_total, queue_depth + from hacklog.security import MessageValidator, build_message_validator +except ImportError: + from config import SyslogConfig + from entities import SyslogMsg + from logging_config import get_logger + from metrics import messages_dropped_total, queue_depth + from security import MessageValidator, build_message_validator + +if TYPE_CHECKING: + from parse import Parser + +logger = get_logger("syslog_server") + +DEFAULT_QUEUE_MAXSIZE = 10_000 +DEFAULT_SHUTDOWN_DRAIN_SECONDS = 30.0 +DEFAULT_PAYLOAD_ENCODING = "utf-8" +_POISON_PILL = object() + + +def syslog_payload_encoding() -> str: + """Return configured syslog payload text encoding (default UTF-8).""" + return os.environ.get("HACKLOG_SYSLOG_ENCODING", DEFAULT_PAYLOAD_ENCODING) + + +def build_validator(syslog_config: SyslogConfig | None = None) -> MessageValidator: + """Build a MessageValidator from syslog configuration.""" + if syslog_config is None: + return build_message_validator() + return build_message_validator( + allowed_cidrs=syslog_config.allowed_cidrs, + max_message_size=syslog_config.max_message_size, + rate_per_second=float(syslog_config.rate_limit_per_source), + burst_capacity=syslog_config.rate_limit_per_source, + ) + + +class SyslogProtocol(asyncio.DatagramProtocol): + """Asyncio datagram protocol for syslog UDP ingestion.""" + + def __init__( + self, + queue: asyncio.Queue[SyslogMsg | object], + validator: MessageValidator, + *, + encoding: str = DEFAULT_PAYLOAD_ENCODING, + accepting: Callable[[], bool], + ) -> None: + self._queue = queue + self._validator = validator + self._encoding = encoding + self._accepting = accepting + self.transport: asyncio.DatagramTransport | None = None + + def connection_made(self, transport: asyncio.BaseTransport) -> None: + self.transport = transport # type: ignore[assignment] + logger.debug( + "udp_listener_started", + operation="connection_made", + ) + + def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None: + if not self._accepting(): + return + + host, port = addr + validation = self._validator.validate(host, data) + if not validation.accepted: + return + + try: + text = data.decode(self._encoding, errors="replace") + except LookupError: + text = data.decode(DEFAULT_PAYLOAD_ENCODING, errors="replace") + + syslog_msg = SyslogMsg(text, host, port) + try: + self._queue.put_nowait(syslog_msg) + queue_depth.set(self._queue.qsize()) + except asyncio.QueueFull: + messages_dropped_total.labels(reason="queue_full").inc() + logger.warning( + "message_dropped", + operation="enqueue_datagram", + source_ip=host, + reason="queue_full", + message_size=len(data), + ) + + def connection_lost(self, exc: Exception | None) -> None: + logger.debug( + "udp_listener_stopped", + operation="connection_lost", + error=str(exc) if exc else None, + ) + + +async def message_consumer( + queue: asyncio.Queue[SyslogMsg | object], + parser: Parser, + process_event: Callable[[object], None], + *, + running: Callable[[], bool], +) -> None: + """Drain the syslog queue and process parsed events.""" + while running() or not queue.empty(): + try: + msg = await asyncio.wait_for(queue.get(), timeout=0.25) + except TimeoutError: + continue + + if msg is _POISON_PILL: + queue.task_done() + break + + if not isinstance(msg, SyslogMsg): + queue.task_done() + continue + + try: + queue_depth.set(queue.qsize()) + event_log = parser.parse_log_line(msg) + if event_log is not None: + process_event(event_log) + logger.debug( + "message_processed", + operation="process_message", + queue_size=queue.qsize(), + source_host=msg.host, + source_port=msg.port, + ) + finally: + queue.task_done() + + +async def run_async_syslog_server( + *, + bind_address: str, + port: int, + parser: Parser, + process_event: Callable[[object], None], + syslog_config: SyslogConfig | None = None, + queue: asyncio.Queue[SyslogMsg | object] | None = None, + queue_maxsize: int = DEFAULT_QUEUE_MAXSIZE, + shutdown_drain_seconds: float = DEFAULT_SHUTDOWN_DRAIN_SECONDS, + encoding: str | None = None, + on_shutdown: Callable[[], None] | None = None, +) -> None: + """Run the asyncio syslog UDP server until SIGINT or SIGTERM.""" + loop = asyncio.get_running_loop() + if queue is None: + queue = asyncio.Queue(maxsize=queue_maxsize) + validator = build_validator(syslog_config) + accepting = True + running = True + shutdown_requested = asyncio.Event() + shutdown_signals = (signal.SIGINT, signal.SIGTERM) + + def stop_accepting() -> None: + nonlocal accepting + accepting = False + + def is_accepting() -> bool: + return accepting + + def is_running() -> bool: + return running + + def request_shutdown() -> None: + logger.info("shutdown_started", operation="handle_signal") + stop_accepting() + shutdown_requested.set() + + for sig in shutdown_signals: + loop.add_signal_handler(sig, request_shutdown) + + transport, _protocol = await loop.create_datagram_endpoint( + lambda: SyslogProtocol( + queue, + validator, + encoding=encoding or syslog_payload_encoding(), + accepting=is_accepting, + ), + local_addr=(bind_address, port), + ) + + consumer_task = asyncio.create_task( + message_consumer(queue, parser, process_event, running=is_running) + ) + + logger.info( + "syslog_server_listening", + operation="start_listener", + bind_address=bind_address, + port=port, + queue_maxsize=queue_maxsize, + ) + + queue_drained = True + try: + await shutdown_requested.wait() + running = False + + try: + await asyncio.wait_for(queue.join(), timeout=shutdown_drain_seconds) + except TimeoutError: + queue_drained = False + logger.warning( + "shutdown_queue_drain_timeout", + operation="drain_queue", + timeout_seconds=shutdown_drain_seconds, + remaining=queue.qsize(), + ) + + try: + queue.put_nowait(_POISON_PILL) + except asyncio.QueueFull: + await queue.put(_POISON_PILL) + + await consumer_task + finally: + transport.close() + for sig in shutdown_signals: + try: + loop.remove_signal_handler(sig) + except (NotImplementedError, RuntimeError): + pass + if on_shutdown is not None: + on_shutdown() + logger.info( + "shutdown_complete", + operation="shutdown", + queue_drained=queue_drained, + remaining=queue.qsize(), + ) diff --git a/hacklog/validators.py b/hacklog/validators.py new file mode 100644 index 0000000..977dc7d --- /dev/null +++ b/hacklog/validators.py @@ -0,0 +1,123 @@ +"""Allow-list validation for parsed syslog fields.""" + +import ipaddress +import re +from dataclasses import dataclass + +try: + from hacklog.logging_config import get_logger + from hacklog.metrics import messages_dropped_total +except ImportError: + from logging_config import get_logger + from metrics import messages_dropped_total + +logger = get_logger("validators") + +USERNAME_PATTERN = re.compile(r"^[a-zA-Z0-9_-]+$") +HOSTNAME_PATTERN = re.compile(r"^[a-zA-Z0-9._-]+$") + +INJECTION_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = ( + ( + "sql_injection", + re.compile(r"(?i)(?:;\s*drop\s+table|'\s*or\s+'1'\s*=\s*'1|union\s+select)"), + ), + ("shell_injection", re.compile(r"\$\(|`|\|\|")), + ("ldap_injection", re.compile(r"\*\)|\(\||\*\(\|")), +) + + +@dataclass(frozen=True) +class FieldValidationResult: + """Outcome of validating a single parsed syslog field.""" + + valid: bool + field_name: str + reason: str | None = None + + +def sanitize_for_log(value: str, max_length: int = 128) -> str: + """Return a log-safe representation of a rejected field value.""" + escaped = value.encode("unicode_escape", errors="backslashreplace").decode("ascii") + if len(escaped) > max_length: + return f"{escaped[:max_length]}..." + return escaped + + +def _has_control_characters(value: str) -> bool: + return any(ord(character) < 32 for character in value) + + +def _contains_injection_pattern(value: str) -> str | None: + for reason, pattern in INJECTION_PATTERNS: + if pattern.search(value): + return reason + return None + + +def validate_username(value: str) -> FieldValidationResult: + if _has_control_characters(value): + return FieldValidationResult(False, "username", "control_characters") + injection = _contains_injection_pattern(value) + if injection: + return FieldValidationResult(False, "username", injection) + if not USERNAME_PATTERN.fullmatch(value): + return FieldValidationResult(False, "username", "invalid_username") + return FieldValidationResult(True, "username") + + +def validate_ip_address(value: str) -> FieldValidationResult: + if _has_control_characters(value): + return FieldValidationResult(False, "ip_address", "control_characters") + injection = _contains_injection_pattern(value) + if injection: + return FieldValidationResult(False, "ip_address", injection) + try: + ipaddress.ip_address(value) + except ValueError: + return FieldValidationResult(False, "ip_address", "invalid_ip_address") + return FieldValidationResult(True, "ip_address") + + +def validate_hostname(value: str) -> FieldValidationResult: + if _has_control_characters(value): + return FieldValidationResult(False, "hostname", "control_characters") + injection = _contains_injection_pattern(value) + if injection: + return FieldValidationResult(False, "hostname", injection) + if not HOSTNAME_PATTERN.fullmatch(value): + return FieldValidationResult(False, "hostname", "invalid_hostname") + return FieldValidationResult(True, "hostname") + + +def validate_parsed_fields( + username: str, + ip_address: str, + hostname: str, + *, + meter_and_log: bool = True, +) -> bool: + """Validate extracted syslog fields before EventLog creation.""" + checks = ( + validate_username(username), + validate_ip_address(ip_address), + validate_hostname(hostname), + ) + for result in checks: + if result.valid: + continue + if meter_and_log: + field_value = { + "username": username, + "ip_address": ip_address, + "hostname": hostname, + }[result.field_name] + messages_dropped_total.labels(reason="invalid_field").inc() + logger.warning( + "parsed_field_rejected", + operation="validate_parsed_fields", + field=result.field_name, + reason=result.reason, + field_value=sanitize_for_log(field_value), + ) + return False + return True diff --git a/healthcheck.py b/healthcheck.py new file mode 100644 index 0000000..0ce2db2 --- /dev/null +++ b/healthcheck.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +"""Docker health check for hacklog: verifies the syslog UDP port is bound.""" +import os +import socket +import sys + +port = int(os.environ.get("HACKLOG_SYSLOG_PORT", "10514")) +# Bind to loopback to probe whether the syslog port is already in use. +# If the server listens on 0.0.0.0 or 127.0.0.1, this bind attempt conflicts. +bind_addr = "127.0.0.1" + +s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +try: + s.bind((bind_addr, port)) + # Successfully bound → port is free → server is NOT running → unhealthy + s.close() + sys.exit(1) +except OSError: + # Could not bind → port already in use → server IS running → healthy + sys.exit(0) diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..f9bfcdd --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,61 @@ +import os +import sys +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +# Allow imports from hacklog package and legacy flat modules. +_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +_HACKLOG_DIR = os.path.join(_REPO_ROOT, "hacklog") +for _path in (_REPO_ROOT, _HACKLOG_DIR): + if _path not in sys.path: + sys.path.insert(0, _path) + +from entities import Base # noqa: E402 + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + +def _database_url() -> str: + return os.environ.get("HACKLOG_DB_URL") or config.get_main_option("sqlalchemy.url") + +def run_migrations_offline() -> None: + context.configure( + url=_database_url(), + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + render_as_batch=True, + ) + + with context.begin_transaction(): + context.run_migrations() + +def run_migrations_online() -> None: + configuration = config.get_section(config.config_ini_section, {}) + configuration["sqlalchemy.url"] = _database_url() + connectable = engine_from_config( + configuration, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + render_as_batch=True, + ) + + with context.begin_transaction(): + context.run_migrations() + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/001_pickle_to_json.py b/migrations/versions/001_pickle_to_json.py new file mode 100644 index 0000000..7bc3656 --- /dev/null +++ b/migrations/versions/001_pickle_to_json.py @@ -0,0 +1,180 @@ +"""Convert PickleType profile columns to JSON. + +Pre-migration backup: + Copies the SQLite database file to ``.pre-migration.bak`` before + any schema or data changes are applied. + +Rollback instructions: + 1. Stop the Hacklog application. + 2. Run ``alembic downgrade -1`` to convert JSON profiles back to pickle blobs. + 3. If downgrade data conversion fails, restore from ``.pre-migration.bak``. + +Revision ID: 001_pickle_json +Revises: +Create Date: 2026-08-07 +""" + +import json +import pickle +import shutil +from pathlib import Path +from typing import Any +from urllib.parse import unquote, urlparse + +import sqlalchemy as sa +from alembic import op + +revision = "001_pickle_json" +down_revision = None +branch_labels = None +depends_on = None + +PROFILE_TABLES = ("days", "hours", "servers", "ipAddress") + +def _sqlite_path_from_url(url: str) -> Path | None: + parsed = urlparse(url) + if parsed.scheme != "sqlite": + return None + database = unquote(parsed.path or "") + if not database or database == ":memory:": + return None + if database.startswith("/"): + return Path(database) + return Path(database) + +def _backup_sqlite_database(connection: sa.Connection) -> Path | None: + db_path = _sqlite_path_from_url(str(connection.engine.url)) + if db_path is None: + return None + backup_path = db_path.with_suffix(db_path.suffix + ".pre-migration.bak") + shutil.copy2(db_path, backup_path) + return backup_path + +def _deserialize_pickle_profile(raw: Any) -> dict[str, Any]: + if raw is None: + return {} + if isinstance(raw, dict): + return raw + if isinstance(raw, str): + return json.loads(raw) + if isinstance(raw, memoryview): + raw = raw.tobytes() + try: + loaded = pickle.loads(raw, encoding="latin1") + except Exception: + loaded = pickle.loads(raw) + if not isinstance(loaded, dict): + raise TypeError(f"Expected profile dict, got {type(loaded)!r}") + return loaded + +def _snapshot_profiles(connection: sa.Connection) -> dict[str, list[dict[str, Any]]]: + snapshots: dict[str, list[dict[str, Any]]] = {} + for table in PROFILE_TABLES: + rows = connection.execute( + sa.text( + f"SELECT date, username, profile, totalCount FROM {table}" # noqa: S608 + ) + ).mappings() + snapshots[table] = [ + { + "date": row["date"], + "username": row["username"], + "profile": _deserialize_pickle_profile(row["profile"]), + "totalCount": row["totalCount"], + } + for row in rows + ] + return snapshots + +def _alter_profile_column_to_json(table: str) -> None: + with op.batch_alter_table(table) as batch_op: + batch_op.alter_column( + "profile", + existing_type=sa.LargeBinary(), + type_=sa.JSON(), + existing_nullable=True, + ) + +def _write_json_profiles( + connection: sa.Connection, snapshots: dict[str, list[dict[str, Any]]] +) -> None: + for table, rows in snapshots.items(): + for row in rows: + connection.execute( + sa.text(f""" + UPDATE {table} + SET profile = :profile + WHERE date = :date AND username = :username + """), # noqa: S608 + { + "profile": json.dumps(row["profile"]), + "date": row["date"], + "username": row["username"], + }, + ) + +def upgrade() -> None: + bind = op.get_bind() + _backup_sqlite_database(bind) + snapshots = _snapshot_profiles(bind) + + for table in PROFILE_TABLES: + _alter_profile_column_to_json(table) + + _write_json_profiles(bind, snapshots) + +def _alter_profile_column_to_pickle(table: str) -> None: + with op.batch_alter_table(table) as batch_op: + batch_op.alter_column( + "profile", + existing_type=sa.JSON(), + type_=sa.LargeBinary(), + existing_nullable=True, + ) + +def _serialize_profile_to_pickle(profile: Any) -> bytes: + if profile is None: + return pickle.dumps({}) + if isinstance(profile, (bytes, bytearray, memoryview)): + return bytes(profile) + if isinstance(profile, str): + profile = json.loads(profile) + return pickle.dumps(profile) + +def downgrade() -> None: + bind = op.get_bind() + snapshots: dict[str, list[dict[str, Any]]] = {} + + for table in PROFILE_TABLES: + rows = bind.execute( + sa.text( + f"SELECT date, username, profile, totalCount FROM {table}" # noqa: S608 + ) + ).mappings() + snapshots[table] = [ + { + "date": row["date"], + "username": row["username"], + "profile": row["profile"], + "totalCount": row["totalCount"], + } + for row in rows + ] + + for table in PROFILE_TABLES: + _alter_profile_column_to_pickle(table) + + for table, rows in snapshots.items(): + for row in rows: + bind.execute( + sa.text(f""" + UPDATE {table} + SET profile = :profile + WHERE date = :date AND username = :username + """), # noqa: S608 + { + "profile": _serialize_profile_to_pickle(row["profile"]), + "date": row["date"], + "username": row["username"], + }, + ) diff --git a/migrations/versions/002_rename_servers_table.py b/migrations/versions/002_rename_servers_table.py new file mode 100644 index 0000000..8e5a41d --- /dev/null +++ b/migrations/versions/002_rename_servers_table.py @@ -0,0 +1,19 @@ +"""Rename servers table to server for singular entity naming. + +Revision ID: 002_rename_servers +Revises: 001_pickle_json +Create Date: 2026-08-07 +""" + +from alembic import op + +revision = "002_rename_servers" +down_revision = "001_pickle_json" +branch_labels = None +depends_on = None + +def upgrade() -> None: + op.rename_table("servers", "server") + +def downgrade() -> None: + op.rename_table("server", "servers") diff --git a/migrations/versions/003_create_audit_table.py b/migrations/versions/003_create_audit_table.py new file mode 100644 index 0000000..d5f8fa7 --- /dev/null +++ b/migrations/versions/003_create_audit_table.py @@ -0,0 +1,38 @@ +"""Create audit_records table for immutable scoring and alerting audit trail. + +Revision ID: 003_create_audit +Revises: 002_rename_servers +Create Date: 2026-08-07 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "003_create_audit" +down_revision = "002_rename_servers" +branch_labels = None +depends_on = None + +def upgrade() -> None: + op.create_table( + "audit_records", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("timestamp", sa.DateTime(), nullable=False), + sa.Column("actor", sa.String(), nullable=False), + sa.Column("source_ip", sa.String(), nullable=True), + sa.Column("resource", sa.String(), nullable=True), + sa.Column("action", sa.String(), nullable=False), + sa.Column("outcome", sa.String(), nullable=True), + sa.Column("details", sa.JSON(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_audit_records_timestamp", + "audit_records", + ["timestamp"], + unique=False, + ) + +def downgrade() -> None: + op.drop_index("ix_audit_records_timestamp", table_name="audit_records") + op.drop_table("audit_records") diff --git a/migrations/versions/004_unify_profile_tables.py b/migrations/versions/004_unify_profile_tables.py new file mode 100644 index 0000000..963cabe --- /dev/null +++ b/migrations/versions/004_unify_profile_tables.py @@ -0,0 +1,103 @@ +"""Consolidate days/hours/server/ipAddress tables into profiles. + +Revision ID: 004_unify_profiles +Revises: 003_create_audit +Create Date: 2026-08-08 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "004_unify_profiles" +down_revision = "003_create_audit" +branch_labels = None +depends_on = None + +PROFILE_SOURCES = ( + ("days", "days"), + ("hours", "hours"), + ("server", "server"), + ("ipAddress", "ipAddress"), +) + + +def upgrade() -> None: + op.create_table( + "profiles", + sa.Column("profileType", sa.String(), nullable=False), + sa.Column("username", sa.String(), nullable=False), + sa.Column("date", sa.DateTime(), nullable=True), + sa.Column("profile", sa.JSON(), nullable=True), + sa.Column("totalCount", sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint("profileType", "username"), + ) + + connection = op.get_bind() + inspector = sa.inspect(connection) + existing_tables = set(inspector.get_table_names()) + + for table_name, profile_type in PROFILE_SOURCES: + if table_name not in existing_tables: + continue + connection.execute( + sa.text( + """ + INSERT INTO profiles (profileType, username, date, profile, totalCount) + SELECT :profile_type, username, date, profile, totalCount + FROM """ + + table_name + ), + {"profile_type": profile_type}, + ) + op.drop_table(table_name) + + +def downgrade() -> None: + op.create_table( + "days", + sa.Column("date", sa.DateTime(), nullable=False), + sa.Column("username", sa.String(), nullable=False), + sa.Column("profile", sa.JSON(), nullable=True), + sa.Column("totalCount", sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint("date", "username"), + ) + op.create_table( + "hours", + sa.Column("date", sa.DateTime(), nullable=False), + sa.Column("username", sa.String(), nullable=False), + sa.Column("profile", sa.JSON(), nullable=True), + sa.Column("totalCount", sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint("date", "username"), + ) + op.create_table( + "server", + sa.Column("date", sa.DateTime(), nullable=False), + sa.Column("username", sa.String(), nullable=False), + sa.Column("profile", sa.JSON(), nullable=True), + sa.Column("totalCount", sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint("date", "username"), + ) + op.create_table( + "ipAddress", + sa.Column("date", sa.DateTime(), nullable=False), + sa.Column("username", sa.String(), nullable=False), + sa.Column("profile", sa.JSON(), nullable=True), + sa.Column("totalCount", sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint("date", "username"), + ) + + connection = op.get_bind() + for table_name, profile_type in PROFILE_SOURCES: + connection.execute( + sa.text( + f""" + INSERT INTO {table_name} (date, username, profile, totalCount) + SELECT date, username, profile, totalCount + FROM profiles + WHERE profileType = :profile_type + """ + ), + {"profile_type": profile_type}, + ) + + op.drop_table("profiles") diff --git a/prometheus.yml b/prometheus.yml new file mode 100644 index 0000000..6a12788 --- /dev/null +++ b/prometheus.yml @@ -0,0 +1,16 @@ +# Prometheus configuration for hacklog dev/test environment. +# Used by docker-compose.yml when the "monitoring" profile is active. +# +# Start with: docker compose --profile monitoring up -d +# Then visit: http://localhost:9091 + +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: hacklog + static_configs: + - targets: + - hacklog:9090 + metrics_path: /metrics diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9115b1b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,90 @@ +[build-system] +requires = ["hatchling>=1.24.0"] +build-backend = "hatchling.build" + +[project] +name = "hacklog" +version = "0.0.5" +description = "Syslog server for detection of compromised user accounts by applying statistical analysis to server authentication logs" +readme = "README.md" +license = "GPL-3.0" +requires-python = ">=3.12" +authors = [ + { name = "DandB Hackweek Team - Hackling Ouliers", email = "hacklog@dandb.com" }, +] +keywords = ["hacking", "security", "logs", "syslog", "outliers", "statistical analysis"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Topic :: Internet :: Log Analysis", + "Topic :: System :: Logging", + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", +] +dependencies = [ + "sqlalchemy>=2.0", + "aiosmtplib", + "pydantic-settings", + "structlog", + "pyyaml>=6.0", + "prometheus-client>=0.20", + "alembic>=1.13", +] + +[project.optional-dependencies] +test = [ + "pytest", + "pytest-asyncio", + "pytest-cov", + "hypothesis", + "coverage", + "bandit", +] +dev = [ + "ruff", + "black", + "isort", + "mypy", + "types-PyYAML", +] + +[project.urls] +Homepage = "https://github.com/dandb/hacklog" +Repository = "https://github.com/dandb/hacklog" + +[tool.hatch.build.targets.wheel] +packages = ["hacklog"] + +[tool.ruff] +target-version = "py312" +line-length = 88 + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W"] +ignore = ["E501", "E402"] + +[tool.mypy] +python_version = "3.12" +warn_return_any = true +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = [ + "hacklog.validators", + "hacklog.security", + "hacklog.metrics", +] +disallow_untyped_defs = true +disable_error_code = ["no-redef", "no-any-return"] + +[tool.black] +line-length = 88 +target-version = ["py312"] + +[tool.isort] +profile = "black" +line_length = 88 + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" diff --git a/requirements-ci.txt b/requirements-ci.txt new file mode 100644 index 0000000..05c544d --- /dev/null +++ b/requirements-ci.txt @@ -0,0 +1,120 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# pip-compile --extra=dev --extra=test --output-file=requirements-ci.txt pyproject.toml +# +aiosmtplib==5.1.2 + # via hacklog (pyproject.toml) +alembic==1.19.0 + # via hacklog (pyproject.toml) +annotated-types==0.8.0 + # via pydantic +ast-serialize==0.8.0 + # via mypy +bandit==1.9.4 + # via hacklog (pyproject.toml) +black==26.5.1 + # via hacklog (pyproject.toml) +click==8.4.2 + # via black +coverage[toml]==7.15.4 + # via + # hacklog (pyproject.toml) + # pytest-cov +greenlet==3.5.4 + # via sqlalchemy +hypothesis==6.165.2 + # via hacklog (pyproject.toml) +iniconfig==2.3.0 + # via pytest +isort==8.0.1 + # via hacklog (pyproject.toml) +librt==0.15.0 + # via mypy +mako==1.4.1 + # via alembic +markdown-it-py==4.2.0 + # via rich +markupsafe==3.0.3 + # via mako +mdurl==0.1.2 + # via markdown-it-py +mypy==2.3.0 + # via hacklog (pyproject.toml) +mypy-extensions==1.1.0 + # via + # black + # mypy +packaging==26.3 + # via + # black + # pytest +pathspec==1.1.1 + # via + # black + # mypy +platformdirs==4.11.0 + # via black +pluggy==1.6.0 + # via + # pytest + # pytest-cov +prometheus-client==0.26.0 + # via hacklog (pyproject.toml) +pydantic==2.13.4 + # via pydantic-settings +pydantic-core==2.46.4 + # via pydantic +pydantic-settings==2.15.0 + # via hacklog (pyproject.toml) +pygments==2.20.0 + # via + # pytest + # rich +pytest==9.1.1 + # via + # hacklog (pyproject.toml) + # pytest-asyncio + # pytest-cov +pytest-asyncio==1.4.0 + # via hacklog (pyproject.toml) +pytest-cov==7.1.0 + # via hacklog (pyproject.toml) +python-dotenv==1.2.2 + # via pydantic-settings +pytokens==0.4.1 + # via black +pyyaml==6.0.3 + # via + # bandit + # hacklog (pyproject.toml) +rich==15.0.0 + # via bandit +ruff==0.16.2 + # via hacklog (pyproject.toml) +sortedcontainers==2.4.0 + # via hypothesis +sqlalchemy==2.0.51 + # via + # alembic + # hacklog (pyproject.toml) +stevedore==5.9.0 + # via bandit +structlog==26.1.0 + # via hacklog (pyproject.toml) +types-pyyaml==6.0.12.20260724 + # via hacklog (pyproject.toml) +typing-extensions==4.16.0 + # via + # alembic + # mypy + # pydantic + # pydantic-core + # pytest-asyncio + # sqlalchemy + # typing-inspection +typing-inspection==0.4.2 + # via + # pydantic + # pydantic-settings diff --git a/requirements-runtime.txt b/requirements-runtime.txt new file mode 100644 index 0000000..66522b4 --- /dev/null +++ b/requirements-runtime.txt @@ -0,0 +1,488 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# pip-compile --generate-hashes --output-file=requirements-runtime.txt pyproject.toml +# +aiosmtplib==5.1.2 \ + --hash=sha256:04a0ea3c678f5b719f998f290dce010ca512e1385836d3944206299df03b060f \ + --hash=sha256:070d467cc329dafd0af59108ba5d217d973cba10309910fed359a2a7bfb52d7a + # via hacklog (pyproject.toml) +alembic==1.19.0 \ + --hash=sha256:6487c612fc719dcfa22b17d2dd5b2b458929641e6aa2f0b65b135727f5e6d501 \ + --hash=sha256:cf839d3849116aab3cc047e09c6968b9bd6b2fde61b6bb7c1e97352fe5503580 + # via hacklog (pyproject.toml) +annotated-types==0.8.0 \ + --hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7 \ + --hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0 + # via pydantic +greenlet==3.5.4 \ + --hash=sha256:0232ae1de90a8e07867bb127d7a6ba2301e859145489f25cda8a6096dabe1d20 \ + --hash=sha256:07bd44616608d873d06735b63ef1a88191d6ca57c8d291d6559c71bc14c0893c \ + --hash=sha256:08fc36de8442d5c3e95b044550dbea9bf144d31ec0cc58e36fb241cb6ef6a994 \ + --hash=sha256:0fa53040b78b578120eecdc0265e3f1051487cc425d11a2b7c761daadf4feaa8 \ + --hash=sha256:123aa379c962ed5fe90a880327e0c3066124ac64ec99e12a238be9fd8eb3db3d \ + --hash=sha256:12cda9122e03341f1cb6b8207a19d7a9d375e52f1b4e9243918375f40fd7b4b9 \ + --hash=sha256:13b980043cb1b3134e81ea469da1250ddcc6bfe6d245bbaa59168d9cdc8f228f \ + --hash=sha256:178111881dd7a6c946471fda85485ec796e1043c2b939f694b096e2ecf986809 \ + --hash=sha256:1833637f17d5e7472548a48575c394fe39f1b1890d676d162d86593610f44d8c \ + --hash=sha256:188e4d142f243051d92a1f5c244a741da02dddc070a0620c842804d7b56d008c \ + --hash=sha256:1e1a4a684b16c45ba324e60b32a4386a87722bcb815d2a149d2182f9b401ca72 \ + --hash=sha256:1f17e362d78e37559e0506c5a7d066bdd45073c36a0127a543e8a0df27242ff3 \ + --hash=sha256:24e61b88cb7e1b1d794b32a10cc346ac779681d6d74ff137a3e0a444d2bf1f02 \ + --hash=sha256:27d3f00718634d4520a3a150154ac5da36f257869d41321953375b90bfbbc72c \ + --hash=sha256:2a924f15d17957e252a810acefcb5942f5ca712298e8b6fcaed9a307d357522c \ + --hash=sha256:2cdaadc3d31445a8f782bde3cd37e49a2c2a9c6da6daf76a3e34c683b271a3c7 \ + --hash=sha256:2ffbc533e0eaf8e80d8471411646ab88fe58f641d508c0b02b24494479f4d9ec \ + --hash=sha256:305f69e6c4523d7f6979ed001cff4e5853c063e5da04880296603aa0227e544c \ + --hash=sha256:32802705c2c1ff25e8237b3bdacf2594fa02be80af8a66703eb7853ea7e68686 \ + --hash=sha256:3529a8a933582ad19e224792cac7372489526576b75b4c124e8e4f29948f4861 \ + --hash=sha256:39169a11d87a6a263afda3e9a27d1df16d0f919d40a4837cc73986c9884c0dd8 \ + --hash=sha256:394de08dad5ffcb1f50c2159d93e398d9d2da3ed437645eaa54771fa720db9f0 \ + --hash=sha256:3d66250e8b09f182ede05490998c818b5961f7a3640332d44c4927caec7bbfe4 \ + --hash=sha256:3ef964f56dfcb6f9bbef2a190d9126795eac408716aeae47b5e7c73c32aafca9 \ + --hash=sha256:41ddab54e4b238f4a6c323f39b4e59e176affd5a94d461a9fb7583dac74240a3 \ + --hash=sha256:42afdc1ab5f66da8c586c32af9224a74a706b4f0ea0dc3a4188a0860a09c65c9 \ + --hash=sha256:4ab9f0704bccf6d3b38e0d2130b7b33271cff11453690da074fa280c3aa8e8e7 \ + --hash=sha256:57aa201b351f7c7c75627c60d29e4d5b97a07d37efeb62b903466fca42c097d7 \ + --hash=sha256:58023945f421093de5e6fa108c0985a8659d43f49e0216da25099369a121bcbd \ + --hash=sha256:60149df8f462d1b230038e6590c23c3b4768bb5d6c022b3b6e82532b34b0b8a3 \ + --hash=sha256:60e0bc961d367df506660e9ac0177a76bc6d81305300704b0977d1634f76efe2 \ + --hash=sha256:69173331fbc5d64bfac0065d7e22c39cfcd089e9b18d125bdcd5079363b09616 \ + --hash=sha256:70bdfacdc183dac838b2a0aaff2dd6134a457c52fe68a9c6bbab435483d2b9df \ + --hash=sha256:73b37afe369021423ea53dd3123e04bffa7e93ac64429b9f50835b2e4fcae7cf \ + --hash=sha256:77d6ce04fed0d9aeed42e0f37923cc43eba9b027bdd9c34546bb4ccd143d0fe0 \ + --hash=sha256:791fdfeeb9c6e0c7b10fa151bf110d2a6974866f13dcb5b1c7efae698245893a \ + --hash=sha256:7c1303791d603080cac6fc3b34df51c3b75b723739c282c8029e48a0d241672f \ + --hash=sha256:7e8afa5eac028f8140ceafe5ceec66e6aa127ddcb21452d2a564dcd2900b5f22 \ + --hash=sha256:870d730fec833f5a06906a32596cc099b9161594642a92a520b7a88911c95356 \ + --hash=sha256:89f3738167bab8c1084b94e23023d41d247117ac149fa0fbcb5bd4cf6262b353 \ + --hash=sha256:91c26423753b92caf41ab3f98fd547d7374d4d9fc2d85be041886c1579d9255e \ + --hash=sha256:9667862a2e38ad379f11b845daeda22c8989186def44f06962c9c4c05e556da7 \ + --hash=sha256:99e8f8c4ebc4fd80aa26c1280ae9ad43a0976e786349703a181cf0bae60413e5 \ + --hash=sha256:9c53ff01a5c53a40f2c16820ebc56d7c61a77f5fbe009dadd96292d5682f80f8 \ + --hash=sha256:9f1467de1bb767f75db0aa34c195e3a496d8d1278c796e70c24ce205d3e99cde \ + --hash=sha256:a2d614cb2372c7101a12ea8b96dd56f81c986d247c5a73db67063f3ed1ca4a52 \ + --hash=sha256:ac5bf81d79d2c8eeb2ef6359b2e1687a1e9ebf46c2b1f970da9a9255df51d190 \ + --hash=sha256:adf2244d7f69409925a8f22ed22cc5f93cdfe5c9dc87ff3476be2c2aaae61a05 \ + --hash=sha256:ae53534b5dec0f4c2ec26f898f538dc8ea1ca3ef2927d597a9439e40a09da937 \ + --hash=sha256:b3dabe3e2809013052c68bdf0b7fa5f5f2859c43a80803131ad61af9cabd7867 \ + --hash=sha256:b7a5f095767c4493afcd06067f2bb3b8716e3f3f9e92b99c88e7e99f885b3d4d \ + --hash=sha256:b7c895310363f310361e0fe2072af85269d2a2a285cd04c0c59e79a5e3670dcf \ + --hash=sha256:bae2728e1897aa8df8cb1af38cd48b3a743aefe29372de7b8b7a9f532501e69f \ + --hash=sha256:bd3d1145f603b2db19feb9078c2e6855eb7c67e15580c010ed815cee519b86fd \ + --hash=sha256:c38c902a0986eba1f6e7ba1ab39ad5195926abde90f3fe080e08212db62176da \ + --hash=sha256:c3fe76c2cac86b4f7a1e92865ac0a54384deb05c92986287c1a7110d9bd53071 \ + --hash=sha256:c883d61f2282d72c767a14936641b3efcbde9d82f1080712aaea0b1d3126cb88 \ + --hash=sha256:c90e930c9c192e5b3ee9fb8bcd920ea3926155e2e3ded39fc697323addecee17 \ + --hash=sha256:ca5726c0b08ca35ae873557266a78b2c3f3b2b7d7401aa5ff886c2045dd0111c \ + --hash=sha256:cbd60b5763c6543c1827e48faaf14ea9bfbad245f52b1a4d76a2a2d8884c6c66 \ + --hash=sha256:cd320d998cbaa032932830448e39abf3c6a12901295e386e8114db926e10cffb \ + --hash=sha256:cef589bc65fae02d10bca2ac341191c5b33acc2967892ebf4fcbd10eabb7a74c \ + --hash=sha256:d83ae0e32d14957ab7170785a20f582635c8474deab1bfbb552b17e769a6ce25 \ + --hash=sha256:d84d993f6e575c950d91a23c1345d18fe1a4310d447bf630849d7809196b52f0 \ + --hash=sha256:d92df08dd65fede97fc37aad36c2e9dcda3b31c467f8e0c2c096456cb818e927 \ + --hash=sha256:dc418cf4c873357964d6624445ed09472e50def990c65dd4e76fc3ba8cd9cef6 \ + --hash=sha256:dfc41ae893d9ceaf22c824f2153a88b30651b20e8758c2cd9ac143f23640563c \ + --hash=sha256:e849e6e139b9671adeac505f72fc05f4af7fd1921faef40295e214fc3b361b59 \ + --hash=sha256:e883de250e299654b1f1680f72a1a9f9ba62c9bd1bce84099c90657349a8dfbb \ + --hash=sha256:e9a5e3406e3ed8125ae1a3b37c12f3434e2b1f0fa053197c5557895b4fb09606 \ + --hash=sha256:ec5ff0d1878df6af3bf9b638a5a92a7d5693291de77c91bff10fa48519c604ef \ + --hash=sha256:ecca4d80d55a01ad6b23b33262662956149fbb7b2c6be2910f1705921958cbf3 \ + --hash=sha256:ed17e5f3420360d5b459de8462efb52060399a5326a613d4cde31cef63ef95da \ + --hash=sha256:ee032b91fd8ec29ec6c4cea2b8c561b178435134bd0752c7334b94e9c736c132 \ + --hash=sha256:f00f910f0e7b35416c63b23ad78b769aeccfc1775f712b43c4ee525624a2eef7 \ + --hash=sha256:f260930bbbbcf9caee661211235a5111c86dfe5832fdf6ae4570da1e0995320f \ + --hash=sha256:f680e549edb3eaf21eea4e7fe101e15ec180c74b7879ab46adc080f22d4015d2 \ + --hash=sha256:f88193799d43dbf8c8a806d6405c9c52fe2af40bf75072a606357b33cc336c7f \ + --hash=sha256:f908898d6fa484ce4b6f447ce70ea99b52c503fee419e53cf74d60a16bc9e667 + # via sqlalchemy +mako==1.4.1 \ + --hash=sha256:a359d9a94a541213958742b2698d0a7757bb83551767bc468a74b9905aba9617 \ + --hash=sha256:d7904710b662996425a21627710c4777c45053146942cf8a7aebf757c92b8c27 + # via alembic +markupsafe==3.0.3 \ + --hash=sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f \ + --hash=sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a \ + --hash=sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf \ + --hash=sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19 \ + --hash=sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf \ + --hash=sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c \ + --hash=sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175 \ + --hash=sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219 \ + --hash=sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb \ + --hash=sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6 \ + --hash=sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab \ + --hash=sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26 \ + --hash=sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1 \ + --hash=sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce \ + --hash=sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218 \ + --hash=sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634 \ + --hash=sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695 \ + --hash=sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad \ + --hash=sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73 \ + --hash=sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c \ + --hash=sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe \ + --hash=sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa \ + --hash=sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559 \ + --hash=sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa \ + --hash=sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37 \ + --hash=sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758 \ + --hash=sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f \ + --hash=sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8 \ + --hash=sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d \ + --hash=sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c \ + --hash=sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97 \ + --hash=sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a \ + --hash=sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19 \ + --hash=sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9 \ + --hash=sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9 \ + --hash=sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc \ + --hash=sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2 \ + --hash=sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4 \ + --hash=sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354 \ + --hash=sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50 \ + --hash=sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698 \ + --hash=sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9 \ + --hash=sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b \ + --hash=sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc \ + --hash=sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115 \ + --hash=sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e \ + --hash=sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485 \ + --hash=sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f \ + --hash=sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12 \ + --hash=sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025 \ + --hash=sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009 \ + --hash=sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d \ + --hash=sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b \ + --hash=sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a \ + --hash=sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5 \ + --hash=sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f \ + --hash=sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d \ + --hash=sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1 \ + --hash=sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287 \ + --hash=sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6 \ + --hash=sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f \ + --hash=sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581 \ + --hash=sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed \ + --hash=sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b \ + --hash=sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c \ + --hash=sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026 \ + --hash=sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8 \ + --hash=sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676 \ + --hash=sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6 \ + --hash=sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e \ + --hash=sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d \ + --hash=sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d \ + --hash=sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01 \ + --hash=sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7 \ + --hash=sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419 \ + --hash=sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795 \ + --hash=sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1 \ + --hash=sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5 \ + --hash=sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d \ + --hash=sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42 \ + --hash=sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe \ + --hash=sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda \ + --hash=sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e \ + --hash=sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737 \ + --hash=sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523 \ + --hash=sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591 \ + --hash=sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc \ + --hash=sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a \ + --hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50 + # via mako +prometheus-client==0.26.0 \ + --hash=sha256:04a91bcf94e2cf74a44a1a874d651a2e853ed354b6e822f3b7487751465d5c2b \ + --hash=sha256:fa93d06737aa02bacd05794768508bb97d2fbee28cb3bca04eaae92f0ca953d6 + # via hacklog (pyproject.toml) +pydantic==2.13.4 \ + --hash=sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba \ + --hash=sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6 + # via pydantic-settings +pydantic-core==2.46.4 \ + --hash=sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0 \ + --hash=sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262 \ + --hash=sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda \ + --hash=sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0 \ + --hash=sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e \ + --hash=sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b \ + --hash=sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594 \ + --hash=sha256:10e17cbb10a330363733efc4d7c4d0dd827ac0909b8f6a6542298fed1ea62f29 \ + --hash=sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2 \ + --hash=sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c \ + --hash=sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d \ + --hash=sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398 \ + --hash=sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d \ + --hash=sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3 \ + --hash=sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f \ + --hash=sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb \ + --hash=sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7 \ + --hash=sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5 \ + --hash=sha256:228ee9bae8bef5b1e97ec58302f80357c37199e0d0a99174e138d28e6957b9d9 \ + --hash=sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462 \ + --hash=sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4 \ + --hash=sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b \ + --hash=sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d \ + --hash=sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df \ + --hash=sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2 \ + --hash=sha256:3447661d99f75a3683a4cf5c87da72f2161964611864dbbeac7fbb118bb4bfc0 \ + --hash=sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519 \ + --hash=sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd \ + --hash=sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7 \ + --hash=sha256:3be77f45df024d789a672ae34f8b06fb346c4f9f46ea714956660ea4862e89ac \ + --hash=sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6 \ + --hash=sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565 \ + --hash=sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898 \ + --hash=sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb \ + --hash=sha256:432c179df7874eeb73307aad2df0755e1ae0efa61ff0ea89b93e194411ae3928 \ + --hash=sha256:4a05d69cba51d852c5c3e92758653245a50c0b646ced0cf05bd793ed592839d6 \ + --hash=sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3 \ + --hash=sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a \ + --hash=sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596 \ + --hash=sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987 \ + --hash=sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e \ + --hash=sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d \ + --hash=sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712 \ + --hash=sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008 \ + --hash=sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd \ + --hash=sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1 \ + --hash=sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be \ + --hash=sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea \ + --hash=sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292 \ + --hash=sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33 \ + --hash=sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3 \ + --hash=sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4 \ + --hash=sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b \ + --hash=sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826 \ + --hash=sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac \ + --hash=sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7 \ + --hash=sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d \ + --hash=sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf \ + --hash=sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4 \ + --hash=sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc \ + --hash=sha256:8b9bab013d1c7a79d3501ff86d0bc9c31bf587db4551677b96bec07df78c6b15 \ + --hash=sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3 \ + --hash=sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b \ + --hash=sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914 \ + --hash=sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04 \ + --hash=sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c \ + --hash=sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b \ + --hash=sha256:91a06d2e259ecfbd8c901d70c3c507900458498142b3026a296b7de4d1322cc9 \ + --hash=sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce \ + --hash=sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4 \ + --hash=sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a \ + --hash=sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f \ + --hash=sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424 \ + --hash=sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894 \ + --hash=sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9 \ + --hash=sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76 \ + --hash=sha256:9f444c499b3eefd3a92e348059471ea0c3a6e303d9c1cec09fa748fd9f895201 \ + --hash=sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb \ + --hash=sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109 \ + --hash=sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4 \ + --hash=sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848 \ + --hash=sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526 \ + --hash=sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0 \ + --hash=sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01 \ + --hash=sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458 \ + --hash=sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e \ + --hash=sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba \ + --hash=sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a \ + --hash=sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39 \ + --hash=sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c \ + --hash=sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000 \ + --hash=sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b \ + --hash=sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf \ + --hash=sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4 \ + --hash=sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd \ + --hash=sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28 \ + --hash=sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9 \ + --hash=sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30 \ + --hash=sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983 \ + --hash=sha256:d80ee3d731373b24cebbc10d689ca4ee1875caf0d5703a245db18efd4dd37fc1 \ + --hash=sha256:d995260fdf4e1db774581b4900e0f832abe3c7c84996726bbc161b19c8f29e76 \ + --hash=sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5 \ + --hash=sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4 \ + --hash=sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7 \ + --hash=sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c \ + --hash=sha256:e68b7a074f65a2fd746c52a7ce6142ab7006074ac269ace0c25cd8ba171f8066 \ + --hash=sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3 \ + --hash=sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02 \ + --hash=sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89 \ + --hash=sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50 \ + --hash=sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76 \ + --hash=sha256:f13a646d65d09fbf1bc6b3a9635d30095c8e7e5cc419ff35ecc563c5fd04cd49 \ + --hash=sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b \ + --hash=sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d \ + --hash=sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7 \ + --hash=sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4 \ + --hash=sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c \ + --hash=sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e \ + --hash=sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff \ + --hash=sha256:fd8b3d9fd264be37976686c7f65cd52a83f5e84f4bfd2adf9c1d469676bbb6ae + # via pydantic +pydantic-settings==2.15.0 \ + --hash=sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42 \ + --hash=sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117 + # via hacklog (pyproject.toml) +python-dotenv==1.2.2 \ + --hash=sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a \ + --hash=sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3 + # via pydantic-settings +pyyaml==6.0.3 \ + --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ + --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \ + --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \ + --hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \ + --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \ + --hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \ + --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \ + --hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \ + --hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \ + --hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \ + --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \ + --hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \ + --hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \ + --hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \ + --hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \ + --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \ + --hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \ + --hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \ + --hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \ + --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \ + --hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \ + --hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \ + --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \ + --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \ + --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \ + --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \ + --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \ + --hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \ + --hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \ + --hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \ + --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \ + --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \ + --hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \ + --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \ + --hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \ + --hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \ + --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \ + --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \ + --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \ + --hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \ + --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \ + --hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \ + --hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \ + --hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \ + --hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \ + --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \ + --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \ + --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \ + --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \ + --hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \ + --hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \ + --hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \ + --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \ + --hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \ + --hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \ + --hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \ + --hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \ + --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \ + --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \ + --hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \ + --hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \ + --hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \ + --hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \ + --hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \ + --hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \ + --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \ + --hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \ + --hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \ + --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \ + --hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \ + --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \ + --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \ + --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 + # via hacklog (pyproject.toml) +sqlalchemy==2.0.51 \ + --hash=sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23 \ + --hash=sha256:0592bdadf86ddcabfd72d9ab66ea8a5d8d2cc6be1cc51fa7e66c03868ac5eac1 \ + --hash=sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8 \ + --hash=sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72 \ + --hash=sha256:0e8203d2fbd5c6254692ef0a72c740d75b2f3c7ca345404f4c1a4604813c77c0 \ + --hash=sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5 \ + --hash=sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e \ + --hash=sha256:111604e637da87031255ddc26c7d7bc22bc6af6f5d459ccff3af1b4660233a85 \ + --hash=sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d \ + --hash=sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2 \ + --hash=sha256:1aa10c0daee6705294d181daadaa793221e1a59ed55000a3fab1d42b088ce4ba \ + --hash=sha256:1af05726b3d0cdba1c55284bf408fd3b792e690fe2399bfb8304565551cda652 \ + --hash=sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f \ + --hash=sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9 \ + --hash=sha256:1e47b1199c2e832e325eacabc8d32d2487f58c9358f97e9a00f5eb93c5680d84 \ + --hash=sha256:247acaa29ccef6250dfd6a3eedf8f94ddf23564180a39fe362e32ae9dbdbde46 \ + --hash=sha256:2a97eaad21c84b4ef8010b11eeba9fe6153eb0b3df3ff8b6abc309df1b978ef7 \ + --hash=sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080 \ + --hash=sha256:2e54ff2dd657f2e3e0fbf2b097db1182f7bfea263eca4353f00065bae2a67c3d \ + --hash=sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d \ + --hash=sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54 \ + --hash=sha256:436728ce18a80f6951a1e11cc6112c2ede9faf20766f1a26195a7c441ca12dbd \ + --hash=sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195 \ + --hash=sha256:4a011ea4510683319ce4ed274b56ee05194b39b6da9d09ca7a39388f0fa84dcc \ + --hash=sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e \ + --hash=sha256:59cab3686b1bc039dd9cded2f8d0c08a246e84e76bd4ab5b4f18c7cdae293825 \ + --hash=sha256:6b588fd681ddf0c196b8df1ea49a8913514894b2b8f945a9511b4b48871f99c8 \ + --hash=sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522 \ + --hash=sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491 \ + --hash=sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400 \ + --hash=sha256:740cf6f35351b1ac3d82369152acf1d51d37e3dcf85d4dc0a22ca01410eabe2a \ + --hash=sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07 \ + --hash=sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7 \ + --hash=sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a \ + --hash=sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9 \ + --hash=sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7 \ + --hash=sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499 \ + --hash=sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5 \ + --hash=sha256:a42ad6afcbaaa777241e347aa2e29155993045a0d6b7db74da61053ffe875fe0 \ + --hash=sha256:a5b2ed6d828f1f09bd812861f4f59ca3bc3803f9df871f4555187f0faf018604 \ + --hash=sha256:a6d26094615306d116dd5e4a51b0304c99dd2356fc569eed6922a80a6bd3b265 \ + --hash=sha256:aa18ae738b5170e253ad0bb6c4b0f07585081e8a6e50893e4d911d47b39a0904 \ + --hash=sha256:ad30ae663711786303fbcd46a47516302d201ee49a877cb3fac61f672895110a \ + --hash=sha256:b21f0e7efc7a5c509e953784e9d1575ebb8b4318960e7e7d7a93bb803626cf64 \ + --hash=sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d \ + --hash=sha256:b7f08588854bbb724041d9ae9d980d40040c922382e1d9a2ecb390edc4fd5032 \ + --hash=sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b \ + --hash=sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5 \ + --hash=sha256:bb1f5062f98b0b3290e72b707747fdd7e0f22d6956b236ba7ca7f5c9971d2da2 \ + --hash=sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d \ + --hash=sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389 \ + --hash=sha256:c68568f3facf8f66fa76c60e0ced69b67666ffa9941d1d0a3756fda196049080 \ + --hash=sha256:c95ef01f53233a305a874a44a63fbfb1d81cd79b49de0f8529b3548cde437e37 \ + --hash=sha256:ca216e8af5c05e326efc7e28716ac2381a7cf9791749f5ee1849dccdc99c9b00 \ + --hash=sha256:ca8435d13829b92f4a97362d91975154a4015db3a2634154e1754e9a915e6b86 \ + --hash=sha256:dc261707bf5739aea8a541593f3cc1d463c2701fb05fbcbba0ce031b69a21260 \ + --hash=sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de \ + --hash=sha256:fa268106c8987639a17a18514cfe0cd9bf17420ab887e1e1bf486da8836135b1 + # via + # alembic + # hacklog (pyproject.toml) +structlog==26.1.0 \ + --hash=sha256:e081a26d6c373e6d201eca24eede26d8ffab07f88f477822e679183428d3d91e \ + --hash=sha256:f63a716cbd1b1291cf7661de7794b455acfa4c43c5bcf1630e6ad5ddc1adb3b7 + # via hacklog (pyproject.toml) +typing-extensions==4.16.0 \ + --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ + --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 + # via + # alembic + # pydantic + # pydantic-core + # sqlalchemy + # typing-inspection +typing-inspection==0.4.2 \ + --hash=sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7 \ + --hash=sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464 + # via + # pydantic + # pydantic-settings diff --git a/scripts/dev-status.sh b/scripts/dev-status.sh new file mode 100755 index 0000000..7621db5 --- /dev/null +++ b/scripts/dev-status.sh @@ -0,0 +1,21 @@ +#!/bin/sh +# Report whether the local Hacklog dev server is running. + +set -eu + +ROOT="$(CDPATH= cd "$(dirname "$0")/.." && pwd)" +PIDFILE="${HACKLOG_PIDFILE:-$ROOT/.hacklog-dev.pid}" + +if [ ! -f "$PIDFILE" ]; then + echo "hacklog is stopped (no pid file)" + exit 1 +fi + +PID="$(cat "$PIDFILE")" +if kill -0 "$PID" 2>/dev/null; then + echo "hacklog is running (pid $PID)" + exit 0 +fi + +echo "hacklog is stopped (stale pid file for $PID)" +exit 1 diff --git a/scripts/run.sh b/scripts/run.sh new file mode 100755 index 0000000..5de7a66 --- /dev/null +++ b/scripts/run.sh @@ -0,0 +1,60 @@ +#!/bin/sh +# Start the Hacklog syslog server for local development. +# +# Configuration is loaded from: +# 1. HACKLOG_* environment variables (pydantic-settings / ConfigManager) +# 2. conf/server.conf (legacy bind/port and parser patterns) +# +# Usage: +# cp .env.example .env # set required SMTP secrets +# ./scripts/run.sh +# make dev-start + +set -eu + +ROOT="$(CDPATH= cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +PIDFILE="${HACKLOG_PIDFILE:-$ROOT/.hacklog-dev.pid}" +LOGFILE="${HACKLOG_LOGFILE:-$ROOT/var/log/hacklog-dev.log}" +CONFIG="${HACKLOG_CONFIG:-$ROOT/conf/server.conf}" +PYTHON="${PYTHON:-python3}" + +if [ -f "$PIDFILE" ]; then + OLD_PID="$(cat "$PIDFILE")" + if kill -0 "$OLD_PID" 2>/dev/null; then + echo "hacklog is already running (pid $OLD_PID). Run ./scripts/stop.sh first." >&2 + exit 1 + fi + rm -f "$PIDFILE" +fi + +if [ -f "$ROOT/.env" ]; then + set -a + # shellcheck disable=SC1091 + . "$ROOT/.env" + set +a +fi + +if [ -z "${HACKLOG_SMTP_USER:-}" ] || [ -z "${HACKLOG_SMTP_PASSWORD:-}" ]; then + echo "Missing required HACKLOG_SMTP_* settings." >&2 + echo "Copy .env.example to .env and set SMTP credentials for ConfigManager." >&2 + exit 1 +fi + +export HACKLOG_DATABASE_DB_URL="${HACKLOG_DATABASE_DB_URL:-sqlite:///$ROOT/hacklog.db}" + +if [ ! -f "$CONFIG" ]; then + echo "Configuration file not found: $CONFIG" >&2 + exit 1 +fi + +mkdir -p "$(dirname "$LOGFILE")" + +nohup "$PYTHON" "$ROOT/hacklog/server.py" -c "$CONFIG" >>"$LOGFILE" 2>&1 & +echo $! >"$PIDFILE" + +echo "Started hacklog (pid $(cat "$PIDFILE"))" +echo " config: $CONFIG" +echo " log: $LOGFILE" +echo " env: HACKLOG_* variables via ConfigManager (see hacklog/config.py)" diff --git a/scripts/stop.sh b/scripts/stop.sh new file mode 100755 index 0000000..b94b97a --- /dev/null +++ b/scripts/stop.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Stop the locally running Hacklog server with graceful SIGTERM shutdown. +# +# The server handles SIGTERM/SIGINT and releases database resources before exit +# (see hacklog/syslog_server.py and hacklog/server.py). +# +# Usage: +# ./scripts/stop.sh +# make dev-stop + +set -eu + +ROOT="$(CDPATH= cd "$(dirname "$0")/.." && pwd)" +PIDFILE="${HACKLOG_PIDFILE:-$ROOT/.hacklog-dev.pid}" +TIMEOUT="${HACKLOG_STOP_TIMEOUT:-30}" + +if [ ! -f "$PIDFILE" ]; then + echo "hacklog is not running (no pid file at $PIDFILE)" + exit 0 +fi + +PID="$(cat "$PIDFILE")" + +if ! kill -0 "$PID" 2>/dev/null; then + echo "Removing stale pid file (process $PID is not running)" + rm -f "$PIDFILE" + exit 0 +fi + +echo "Sending SIGTERM to hacklog (pid $PID) for graceful shutdown..." +kill -TERM "$PID" + +elapsed=0 +while kill -0 "$PID" 2>/dev/null; do + if [ "$elapsed" -ge "$TIMEOUT" ]; then + echo "Timed out after ${TIMEOUT}s waiting for graceful shutdown." >&2 + echo "The process may still be running; investigate pid $PID manually." >&2 + exit 1 + fi + sleep 1 + elapsed=$((elapsed + 1)) +done + +rm -f "$PIDFILE" +echo "hacklog stopped gracefully" diff --git a/setup.py b/setup.py deleted file mode 100644 index f816bd5..0000000 --- a/setup.py +++ /dev/null @@ -1,52 +0,0 @@ -import os -import sys -from setuptools import setup, Command - -# Utility function to read the README file. -# Used for the long_description. It's nice, because now 1) we have a top level -# README file and 2) it's easier to type in the README file than to put a raw -# string in below ... - -#from distutils.core import setup, Command -# you can also import from setuptools - -#FIXME: mockito really should not be there, however it does not get installed as test dependecy when added to 'tests_require' -install_requires = [ - 'twisted', - 'SQLAlchemy', - 'mockito', - ] - -tests_require = [ - 'pytest', - 'mockito', - ] - - -def read(fname): - return open(os.path.join(os.path.dirname(__file__), fname)).read() - -setup( - name = "hacklog", - version = "0.0.5", - author = "DandB Hackweek Team - Hackling Ouliers", - author_email = "hacklog@dandb.com", - description = ("Syslog server for detection of compromised user accounts by" - "applying statical analysis to server authentication logs"), - license = "GPLv3", - keywords = "hacking security logs syslog outliers statistical analysis", - url = "https://github.com/dandb/hacklog", - packages=['hacklog'], - install_requires = install_requires, - tests_require = tests_require, - extras_require={'test': tests_require}, - long_description=read('README.md') + '\n\n' + read('CHANGES'), - test_suite = 'tests', - classifiers=[ - "Development Status :: 3 - Alpha", - "Topic :: Internet :: Log Analysis", - "Topic :: System :: Logging", - "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", - ], -) - diff --git a/tests/__init__.py b/tests/__init__.py index ac71c56..f36c642 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,20 +1 @@ -#! /usr/bin/env python - -import unittest, sys -sys.path.append('hacklog') - -def load_tests(loader, tests, pattern): - ''' - Discover and load all unit tests in all files named ``*_test.py`` in ``.`` - ''' - suite = unittest.TestSuite() - for all_test_suite in unittest.defaultTestLoader.discover('.', pattern='*_test.py'): - for test_suite in all_test_suite: - suite.addTests(test_suite) - return suite - -def main(): - unittest.TextTestRunner(verbosity=2).run(suite) - -if __name__ == '__main__': - unittest.main() +"""Hacklog test package — discovered by pytest via pyproject.toml testpaths.""" diff --git a/tests/accessdata_test.py b/tests/accessdata_test.py index f40aff9..567036b 100644 --- a/tests/accessdata_test.py +++ b/tests/accessdata_test.py @@ -1,70 +1,94 @@ +import os +import sys import unittest -from compat import _Compat from datetime import datetime -import sys -from accessdata import * -from entities import * -import re -import os - - -genericDao = GenericDao() -userDao = UserDao() -daysDao = DaysDao() -hoursDao = HoursDao() -serverDao = ServerDao() -ipAddressDao = IpAddressDao() - -class AccessDataTests(unittest.TestCase, _Compat): - +from pathlib import Path + +_TESTS_DIR = Path(__file__).resolve().parent +_HACKLOG_DIR = _TESTS_DIR.parent / "hacklog" +for _path in (_TESTS_DIR, _HACKLOG_DIR, _TESTS_DIR.parent): + if str(_path) not in sys.path: + sys.path.insert(0, str(_path)) + +from accessdata import DaysDao, GenericDao, HoursDao, IpAddressDao, ServerDao, UserDao +from entities import ( + Profile, + ProfileType, + User, + create_db_engine, + create_tables, +) +from session import Session + +generic_dao = GenericDao() +user_dao = UserDao() +days_dao = DaysDao() +hours_dao = HoursDao() +server_dao = ServerDao() +ip_address_dao = IpAddressDao() + + +class AccessDataTests(unittest.TestCase): def setUp(self): - - self._user = User('nrhine', datetime.today(), 10) - - self.dbFile = ':memory:' - - create_db_engine(self) - create_tables() + self._user = User("nrhine", datetime.today(), 10) + self.db_file = ":memory:" + self.engine = create_db_engine(self) + create_tables(self.engine) + Session.configure(bind=self.engine) def tearDown(self): - if self.dbFile != ':memory:': - os.remove(self.dbFile) + if self.db_file != ":memory:": + os.remove(self.db_file) def test_starting_out(self): self.assertEqual(1, 1) def test_save_and_get_user(self): - genericDao.saveEntity(self._user) - userTest = userDao.getUserByName(self._user.username) - self.assertIsInstance(userTest, User) + username = self._user.username + generic_dao.save_entity(self._user) + user_test = user_dao.get_user_by_name(username) + self.assertIsInstance(user_test, User) def test_save_and_get_day(self): - day = Days(datetime.today(), 'nrhine', {}, 0) - genericDao.saveEntity(day) - dayTest = daysDao.getProfileByUser(self._user.username) - self.assertIsInstance(dayTest, Days) + day = Profile(datetime.today(), "nrhine", ProfileType.DAYS, {}, 0) + generic_dao.save_entity(day) + day_test = days_dao.get_profile_by_user(self._user.username) + self.assertIsInstance(day_test, Profile) + self.assertEqual(day_test.profile_type, ProfileType.DAYS.value) def test_save_and_get_hour(self): - hours = Hours(datetime.today(), 'nrhine', {}, 0) - genericDao.saveEntity(hours) - hoursTest = hoursDao.getProfileByUser(self._user.username) - self.assertIsInstance(hoursTest, Hours) + hours = Profile(datetime.today(), "nrhine", ProfileType.HOURS, {}, 0) + generic_dao.save_entity(hours) + hours_test = hours_dao.get_profile_by_user(self._user.username) + self.assertIsInstance(hours_test, Profile) + self.assertEqual(hours_test.profile_type, ProfileType.HOURS.value) def test_save_and_get_server(self): - server = Servers(datetime.today(), 'nrhine', {}, 0) - genericDao.saveEntity(server) - serverTest = serverDao.getProfileByUser(self._user.username) - self.assertIsInstance(serverTest, Servers) + server = Profile(datetime.today(), "nrhine", ProfileType.SERVER, {}, 0) + generic_dao.save_entity(server) + server_test = server_dao.get_profile_by_user(self._user.username) + self.assertIsInstance(server_test, Profile) + self.assertEqual(server_test.profile_type, ProfileType.SERVER.value) + + def test_save_and_get_ip_address(self): + ip_addr = Profile(datetime.today(), "nrhine", ProfileType.IP_ADDRESS, {}, 0) + generic_dao.save_entity(ip_addr) + ip_addr_test = ip_address_dao.get_profile_by_user(self._user.username) + self.assertIsInstance(ip_addr_test, Profile) + self.assertEqual(ip_addr_test.profile_type, ProfileType.IP_ADDRESS.value) + + def test_merge_user_updates_score(self): + generic_dao.save_entity(self._user) + self._user.score = 99 + generic_dao.merge_entity(self._user) + merged = user_dao.get_user_by_name(self._user.username) + self.assertIsInstance(merged, User) + self.assertEqual(merged.score, 99) - def test_save_and_get_ipAddress(self): - ipAddr = IpAddress(datetime.today(), 'nrhine', {}, 0) - genericDao.saveEntity(ipAddr) - ipAddrTest = ipAddressDao.getProfileByUser(self._user.username) - self.assertIsInstance(ipAddrTest, IpAddress) def main(): unittest.main() + if __name__ == "__main__": main() - diff --git a/tests/compat.py b/tests/compat.py deleted file mode 100644 index 255aa60..0000000 --- a/tests/compat.py +++ /dev/null @@ -1,11 +0,0 @@ -# compatibility with python2.6 unittest -import unittest - -if hasattr(unittest.TestCase, 'assertIsInstance'): - class _Compat: pass -else: - class _Compat: - def assertIsInstance(self, obj, cls, msg=None): - if not isinstance(obj, cls): - standardMsg = '%s is not an instance of %r' % (safe_repr(obj), cls) - self.fail(self._formatMessage(msg, standardMsg)) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..62411ff --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,114 @@ +"""Shared pytest fixtures for behavioral and end-to-end pipeline tests.""" + +from __future__ import annotations + +import sys +from datetime import datetime +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest +from pydantic import SecretStr +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +_TESTS_DIR = Path(__file__).resolve().parent +_HACKLOG_DIR = _TESTS_DIR.parent / "hacklog" +for _path in (_HACKLOG_DIR, _TESTS_DIR.parent): + if str(_path) not in sys.path: + sys.path.insert(0, str(_path)) + +from hacklog.alerting import AlertService # noqa: E402 +from hacklog.config import SmtpConfig, SyslogConfig # noqa: E402 +from hacklog.entities import EventLog, User, create_tables # noqa: E402 +from hacklog.scoring import ScoringEngine # noqa: E402 +from hacklog.services import UpdateService # noqa: E402 + + +@pytest.fixture +def sample_event_log() -> EventLog: + return EventLog( + datetime(2026, 1, 15, 10, 0, 0), + "nrhine", + "10.42.10.2", + False, + "prod-host", + ) + + +@pytest.fixture +def mock_scoring_services(): + update_service = MagicMock(spec=UpdateService) + alert_service = MagicMock() + user = User("nrhine", datetime(2026, 1, 15, 10, 0, 0), 0) + user.scare_count = 0 + update_service.fetch_user.return_value = user + update_service.update_and_return_hour_freq_for_user.return_value = 0.5 + update_service.update_and_return_day_freq_for_user.return_value = 0.5 + update_service.update_and_return_server_freq_for_user.return_value = 0.5 + update_service.update_and_return_ip_freq_for_user.return_value = 0.5 + update_service.update_user_scare_count.return_value = user + engine = ScoringEngine(update_service, alert_service) + return engine, update_service, alert_service, user + + +@pytest.fixture +def sqlite_session_factory(): + engine = create_engine("sqlite:///:memory:") + create_tables(engine) + factory = sessionmaker( + bind=engine, autoflush=True, autocommit=False, expire_on_commit=False + ) + yield factory + + +@pytest.fixture +def smtp_config() -> SmtpConfig: + return SmtpConfig( + host="smtp.example.com", + port=587, + username="test@example.com", + password=SecretStr("test-password"), + sender="alerts@example.com", + recipient="soc@example.com", + use_tls=True, + ) + + +@pytest.fixture +def mock_smtp_sender() -> AsyncMock: + return AsyncMock() + + +@pytest.fixture +def e2e_syslog_config() -> SyslogConfig: + return SyslogConfig( + bind_address="127.0.0.1", + max_message_size=2048, + allowed_cidrs=[], + rate_limit_per_source=100, + ) + + +@pytest.fixture +def e2e_services(sqlite_session_factory, smtp_config, mock_smtp_sender): + """Real UpdateService + ScoringEngine with in-memory SQLite and mock SMTP.""" + update_service = UpdateService(session_factory=sqlite_session_factory) + alert_service = AlertService( + smtp_config, + smtp_sender=mock_smtp_sender, + ) + scoring_engine = ScoringEngine(update_service, alert_service) + return scoring_engine, update_service, alert_service, mock_smtp_sender + + +@pytest.fixture +def scoring_golden_events(): + import json + + path = _TESTS_DIR / "fixtures" / "scoring_golden.json" + with path.open(encoding="utf-8") as handle: + payload = json.load(handle) + events = payload["events"] + assert len(events) >= 500 + return events diff --git a/tests/fixtures/injection_messages.py b/tests/fixtures/injection_messages.py new file mode 100644 index 0000000..0ec68be --- /dev/null +++ b/tests/fixtures/injection_messages.py @@ -0,0 +1,35 @@ +"""Injection payloads and valid syslog fixtures for field validation tests.""" + +VALID_SYSLOG_FIXTURES = { + "success_ssh": ( + "<14>sshd[3070]: Accepted publickey for alice from 10.42.10.2 port 2005 ssh2" + ), + "failure_ssh": ( + "<14>sshd[3070]: pam_unix(sshd:auth): authentication failure; login= " + "uid=0 euid=0 tty=ssh ruser= rhost=10.42.10.22 user=bob" + ), +} + +INJECTION_SYSLOG_FIXTURES = { + "sql_username": ( + "<14>sshd[3070]: Accepted publickey for admin'; DROP TABLE users;-- from " + "10.42.10.2 port 2005 ssh2" + ), + "shell_username": ( + "<14>sshd[3070]: Accepted publickey for $(whoami) from 10.42.10.2 port 2005 ssh2" + ), + "ldap_username": ( + "<14>sshd[3070]: Accepted publickey for admin)(|(password=*)) from " + "10.42.10.2 port 2005 ssh2" + ), + "null_byte_username": ( + "<14>sshd[3070]: Accepted publickey for admin\x00evil from 10.42.10.2 port 2005 ssh2" + ), + "invalid_ip": ( + "<14>sshd[3070]: Accepted publickey for alice from 999.999.999.999 port 2005 ssh2" + ), + "invalid_hostname_test_mode": ( + "<14>sshd[4105]: Accepted publickey for alice from 10.42.10.2 port 7786 ssh2 " + "DATE_TIME 2013-09-23 11:16:48 HOST bad host name" + ), +} diff --git a/tests/fixtures/profile_fixtures.json b/tests/fixtures/profile_fixtures.json new file mode 100644 index 0000000..8e0b3d8 --- /dev/null +++ b/tests/fixtures/profile_fixtures.json @@ -0,0 +1,6 @@ +{ + "days": {"Mon": 5, "Tue": 3, "Wed": 1}, + "hours": {"09": 12, "14": 8, "22": 2}, + "servers": {"ldap1": 40, "vpn-gw": 15, "mail": 3}, + "ipAddress": {"10.0.0.5": 20, "203.0.113.1": 1, "special/key": 2} +} diff --git a/tests/fixtures/scoring_golden.json b/tests/fixtures/scoring_golden.json new file mode 100644 index 0000000..4e362c7 --- /dev/null +++ b/tests/fixtures/scoring_golden.json @@ -0,0 +1,14771 @@ +{ + "version": 1, + "description": "Golden-file scoring vectors for hacklog algorithm.py behavioral baseline", + "generated_at": "2026-08-06T00:00:00Z", + "event_count": 527, + "events": [ + { + "id": 1, + "input": { + "date": "2015-06-15T04:30:00", + "username": "nrhine", + "ipAddress": "8.8.8.8", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 15.0 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 2, + "input": { + "date": "2015-06-15T04:30:00", + "username": "jsmith", + "ipAddress": "8.8.8.8", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 32.94867642699313 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 3, + "input": { + "date": "2015-06-15T04:30:00", + "username": "alee", + "ipAddress": "8.8.8.8", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 25.982892142331043 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 4, + "input": { + "date": "2015-06-15T04:30:00", + "username": "mchen", + "ipAddress": "8.8.8.8", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 29.465784284662085 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 5, + "input": { + "date": "2015-06-15T04:30:00", + "username": "kpatel", + "ipAddress": "8.8.8.8", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 49.8802449963173 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 6, + "input": { + "date": "2015-06-15T04:30:00", + "username": "bwong", + "ipAddress": "1.2.3.4", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 15.0 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 7, + "input": { + "date": "2015-06-15T04:30:00", + "username": "tdavis", + "ipAddress": "1.2.3.4", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 32.94867642699313 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 8, + "input": { + "date": "2015-06-15T04:30:00", + "username": "nrhine", + "ipAddress": "1.2.3.4", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 25.982892142331043 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 9, + "input": { + "date": "2015-06-15T04:30:00", + "username": "jsmith", + "ipAddress": "1.2.3.4", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 29.465784284662085 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 10, + "input": { + "date": "2015-06-15T04:30:00", + "username": "alee", + "ipAddress": "1.2.3.4", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 49.8802449963173 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 11, + "input": { + "date": "2015-06-15T04:30:00", + "username": "mchen", + "ipAddress": "203.0.113.50", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 15.0 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 12, + "input": { + "date": "2015-06-15T04:30:00", + "username": "kpatel", + "ipAddress": "203.0.113.50", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 32.94867642699313 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 13, + "input": { + "date": "2015-06-15T04:30:00", + "username": "bwong", + "ipAddress": "203.0.113.50", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 25.982892142331043 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 14, + "input": { + "date": "2015-06-15T04:30:00", + "username": "tdavis", + "ipAddress": "203.0.113.50", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 29.465784284662085 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 15, + "input": { + "date": "2015-06-15T04:30:00", + "username": "nrhine", + "ipAddress": "203.0.113.50", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 49.8802449963173 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 16, + "input": { + "date": "2015-06-15T04:30:00", + "username": "jsmith", + "ipAddress": "10.42.10.5", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 0.0 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 17, + "input": { + "date": "2015-06-15T04:30:00", + "username": "alee", + "ipAddress": "10.42.10.5", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 17.94867642699313 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 18, + "input": { + "date": "2015-06-15T04:30:00", + "username": "mchen", + "ipAddress": "10.42.10.5", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 10.982892142331043 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 19, + "input": { + "date": "2015-06-15T04:30:00", + "username": "kpatel", + "ipAddress": "10.42.10.5", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 14.465784284662089 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 20, + "input": { + "date": "2015-06-15T04:30:00", + "username": "bwong", + "ipAddress": "10.42.10.5", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 34.88024499631731 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 21, + "input": { + "date": "2015-06-15T04:30:00", + "username": "tdavis", + "ipAddress": "10.42.0.1", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 0.0 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 22, + "input": { + "date": "2015-06-15T04:30:00", + "username": "nrhine", + "ipAddress": "10.42.0.1", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 17.94867642699313 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 23, + "input": { + "date": "2015-06-15T04:30:00", + "username": "jsmith", + "ipAddress": "10.42.0.1", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 10.982892142331043 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 24, + "input": { + "date": "2015-06-15T04:30:00", + "username": "alee", + "ipAddress": "10.42.0.1", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 14.465784284662089 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 25, + "input": { + "date": "2015-06-15T04:30:00", + "username": "mchen", + "ipAddress": "10.42.0.1", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 34.88024499631731 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 26, + "input": { + "date": "2015-06-15T04:30:00", + "username": "kpatel", + "ipAddress": "10.24.5.10", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 10.0 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 27, + "input": { + "date": "2015-06-15T04:30:00", + "username": "bwong", + "ipAddress": "10.24.5.10", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 27.94867642699313 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 28, + "input": { + "date": "2015-06-15T04:30:00", + "username": "tdavis", + "ipAddress": "10.24.5.10", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 20.982892142331043 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 29, + "input": { + "date": "2015-06-15T04:30:00", + "username": "nrhine", + "ipAddress": "10.24.5.10", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 24.465784284662085 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 30, + "input": { + "date": "2015-06-15T04:30:00", + "username": "jsmith", + "ipAddress": "10.24.5.10", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 44.8802449963173 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 31, + "input": { + "date": "2015-06-15T04:30:00", + "username": "alee", + "ipAddress": "10.26.8.20", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 10.0 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 32, + "input": { + "date": "2015-06-15T04:30:00", + "username": "mchen", + "ipAddress": "10.26.8.20", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 27.94867642699313 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 33, + "input": { + "date": "2015-06-15T04:30:00", + "username": "kpatel", + "ipAddress": "10.26.8.20", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 20.982892142331043 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 34, + "input": { + "date": "2015-06-15T04:30:00", + "username": "bwong", + "ipAddress": "10.26.8.20", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 24.465784284662085 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 35, + "input": { + "date": "2015-06-15T04:30:00", + "username": "tdavis", + "ipAddress": "10.26.8.20", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 44.8802449963173 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 36, + "input": { + "date": "2015-06-15T04:30:00", + "username": "nrhine", + "ipAddress": "172.16.100.1", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 10.0 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 37, + "input": { + "date": "2015-06-15T04:30:00", + "username": "jsmith", + "ipAddress": "172.16.100.1", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 27.94867642699313 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 38, + "input": { + "date": "2015-06-15T04:30:00", + "username": "alee", + "ipAddress": "172.16.100.1", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 20.982892142331043 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 39, + "input": { + "date": "2015-06-15T04:30:00", + "username": "mchen", + "ipAddress": "172.16.100.1", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 24.465784284662085 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 40, + "input": { + "date": "2015-06-15T04:30:00", + "username": "kpatel", + "ipAddress": "172.16.100.1", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 44.8802449963173 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 41, + "input": { + "date": "2015-06-15T04:30:00", + "username": "bwong", + "ipAddress": "8.8.8.8", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 50.0 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 42, + "input": { + "date": "2015-06-15T04:30:00", + "username": "tdavis", + "ipAddress": "8.8.8.8", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 67.94867642699313 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 43, + "input": { + "date": "2015-06-15T04:30:00", + "username": "nrhine", + "ipAddress": "8.8.8.8", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 60.982892142331046 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 44, + "input": { + "date": "2015-06-15T04:30:00", + "username": "jsmith", + "ipAddress": "8.8.8.8", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 64.46578428466208 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 45, + "input": { + "date": "2015-06-15T04:30:00", + "username": "alee", + "ipAddress": "8.8.8.8", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 84.8802449963173 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 46, + "input": { + "date": "2015-06-15T04:30:00", + "username": "mchen", + "ipAddress": "1.2.3.4", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 50.0 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 47, + "input": { + "date": "2015-06-15T04:30:00", + "username": "kpatel", + "ipAddress": "1.2.3.4", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 67.94867642699313 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 48, + "input": { + "date": "2015-06-15T04:30:00", + "username": "bwong", + "ipAddress": "1.2.3.4", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 60.982892142331046 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 49, + "input": { + "date": "2015-06-15T04:30:00", + "username": "tdavis", + "ipAddress": "1.2.3.4", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 64.46578428466208 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 50, + "input": { + "date": "2015-06-15T04:30:00", + "username": "nrhine", + "ipAddress": "1.2.3.4", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 84.8802449963173 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 51, + "input": { + "date": "2015-06-15T04:30:00", + "username": "jsmith", + "ipAddress": "203.0.113.50", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 50.0 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 52, + "input": { + "date": "2015-06-15T04:30:00", + "username": "alee", + "ipAddress": "203.0.113.50", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 67.94867642699313 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 53, + "input": { + "date": "2015-06-15T04:30:00", + "username": "mchen", + "ipAddress": "203.0.113.50", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 60.982892142331046 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 54, + "input": { + "date": "2015-06-15T04:30:00", + "username": "kpatel", + "ipAddress": "203.0.113.50", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 64.46578428466208 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 55, + "input": { + "date": "2015-06-15T04:30:00", + "username": "bwong", + "ipAddress": "203.0.113.50", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 84.8802449963173 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 56, + "input": { + "date": "2015-06-15T04:30:00", + "username": "tdavis", + "ipAddress": "10.42.10.5", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 35.0 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 57, + "input": { + "date": "2015-06-15T04:30:00", + "username": "nrhine", + "ipAddress": "10.42.10.5", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 52.94867642699313 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 58, + "input": { + "date": "2015-06-15T04:30:00", + "username": "jsmith", + "ipAddress": "10.42.10.5", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 45.982892142331046 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 59, + "input": { + "date": "2015-06-15T04:30:00", + "username": "alee", + "ipAddress": "10.42.10.5", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 49.465784284662085 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 60, + "input": { + "date": "2015-06-15T04:30:00", + "username": "mchen", + "ipAddress": "10.42.10.5", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 69.8802449963173 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 61, + "input": { + "date": "2015-06-15T04:30:00", + "username": "kpatel", + "ipAddress": "10.42.0.1", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 35.0 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 62, + "input": { + "date": "2015-06-15T04:30:00", + "username": "bwong", + "ipAddress": "10.42.0.1", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 52.94867642699313 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 63, + "input": { + "date": "2015-06-15T04:30:00", + "username": "tdavis", + "ipAddress": "10.42.0.1", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 45.982892142331046 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 64, + "input": { + "date": "2015-06-15T04:30:00", + "username": "nrhine", + "ipAddress": "10.42.0.1", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 49.465784284662085 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 65, + "input": { + "date": "2015-06-15T04:30:00", + "username": "jsmith", + "ipAddress": "10.42.0.1", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 69.8802449963173 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 66, + "input": { + "date": "2015-06-15T04:30:00", + "username": "alee", + "ipAddress": "10.24.5.10", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 45.0 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 67, + "input": { + "date": "2015-06-15T04:30:00", + "username": "mchen", + "ipAddress": "10.24.5.10", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 62.94867642699313 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 68, + "input": { + "date": "2015-06-15T04:30:00", + "username": "kpatel", + "ipAddress": "10.24.5.10", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 55.982892142331046 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 69, + "input": { + "date": "2015-06-15T04:30:00", + "username": "bwong", + "ipAddress": "10.24.5.10", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 59.465784284662085 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 70, + "input": { + "date": "2015-06-15T04:30:00", + "username": "tdavis", + "ipAddress": "10.24.5.10", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 79.8802449963173 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 71, + "input": { + "date": "2015-06-15T04:30:00", + "username": "nrhine", + "ipAddress": "10.26.8.20", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 45.0 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 72, + "input": { + "date": "2015-06-15T04:30:00", + "username": "jsmith", + "ipAddress": "10.26.8.20", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 62.94867642699313 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 73, + "input": { + "date": "2015-06-15T04:30:00", + "username": "alee", + "ipAddress": "10.26.8.20", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 55.982892142331046 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 74, + "input": { + "date": "2015-06-15T04:30:00", + "username": "mchen", + "ipAddress": "10.26.8.20", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 59.465784284662085 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 75, + "input": { + "date": "2015-06-15T04:30:00", + "username": "kpatel", + "ipAddress": "10.26.8.20", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 79.8802449963173 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 76, + "input": { + "date": "2015-06-15T04:30:00", + "username": "bwong", + "ipAddress": "172.16.100.1", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 45.0 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 77, + "input": { + "date": "2015-06-15T04:30:00", + "username": "tdavis", + "ipAddress": "172.16.100.1", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 62.94867642699313 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 78, + "input": { + "date": "2015-06-15T04:30:00", + "username": "nrhine", + "ipAddress": "172.16.100.1", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 55.982892142331046 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 79, + "input": { + "date": "2015-06-15T04:30:00", + "username": "jsmith", + "ipAddress": "172.16.100.1", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 59.465784284662085 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 80, + "input": { + "date": "2015-06-15T04:30:00", + "username": "alee", + "ipAddress": "172.16.100.1", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 79.8802449963173 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 81, + "input": { + "date": "2015-06-15T08:30:00", + "username": "mchen", + "ipAddress": "8.8.8.8", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 15.0 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 82, + "input": { + "date": "2015-06-15T08:30:00", + "username": "kpatel", + "ipAddress": "8.8.8.8", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 32.94867642699313 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 83, + "input": { + "date": "2015-06-15T08:30:00", + "username": "bwong", + "ipAddress": "8.8.8.8", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 25.982892142331043 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 84, + "input": { + "date": "2015-06-15T08:30:00", + "username": "tdavis", + "ipAddress": "8.8.8.8", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 29.465784284662085 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 85, + "input": { + "date": "2015-06-15T08:30:00", + "username": "nrhine", + "ipAddress": "8.8.8.8", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 49.8802449963173 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 86, + "input": { + "date": "2015-06-15T08:30:00", + "username": "jsmith", + "ipAddress": "1.2.3.4", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 15.0 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 87, + "input": { + "date": "2015-06-15T08:30:00", + "username": "alee", + "ipAddress": "1.2.3.4", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 32.94867642699313 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 88, + "input": { + "date": "2015-06-15T08:30:00", + "username": "mchen", + "ipAddress": "1.2.3.4", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 25.982892142331043 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 89, + "input": { + "date": "2015-06-15T08:30:00", + "username": "kpatel", + "ipAddress": "1.2.3.4", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 29.465784284662085 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 90, + "input": { + "date": "2015-06-15T08:30:00", + "username": "bwong", + "ipAddress": "1.2.3.4", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 49.8802449963173 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 91, + "input": { + "date": "2015-06-15T08:30:00", + "username": "tdavis", + "ipAddress": "203.0.113.50", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 15.0 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 92, + "input": { + "date": "2015-06-15T08:30:00", + "username": "nrhine", + "ipAddress": "203.0.113.50", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 32.94867642699313 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 93, + "input": { + "date": "2015-06-15T08:30:00", + "username": "jsmith", + "ipAddress": "203.0.113.50", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 25.982892142331043 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 94, + "input": { + "date": "2015-06-15T08:30:00", + "username": "alee", + "ipAddress": "203.0.113.50", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 29.465784284662085 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 95, + "input": { + "date": "2015-06-15T08:30:00", + "username": "mchen", + "ipAddress": "203.0.113.50", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 49.8802449963173 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 96, + "input": { + "date": "2015-06-15T08:30:00", + "username": "kpatel", + "ipAddress": "10.42.10.5", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 0.0 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 97, + "input": { + "date": "2015-06-15T08:30:00", + "username": "bwong", + "ipAddress": "10.42.10.5", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 17.94867642699313 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 98, + "input": { + "date": "2015-06-15T08:30:00", + "username": "tdavis", + "ipAddress": "10.42.10.5", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 10.982892142331043 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 99, + "input": { + "date": "2015-06-15T08:30:00", + "username": "nrhine", + "ipAddress": "10.42.10.5", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 14.465784284662089 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 100, + "input": { + "date": "2015-06-15T08:30:00", + "username": "jsmith", + "ipAddress": "10.42.10.5", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 34.88024499631731 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 101, + "input": { + "date": "2015-06-15T08:30:00", + "username": "alee", + "ipAddress": "10.42.0.1", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 0.0 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 102, + "input": { + "date": "2015-06-15T08:30:00", + "username": "mchen", + "ipAddress": "10.42.0.1", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 17.94867642699313 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 103, + "input": { + "date": "2015-06-15T08:30:00", + "username": "kpatel", + "ipAddress": "10.42.0.1", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 10.982892142331043 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 104, + "input": { + "date": "2015-06-15T08:30:00", + "username": "bwong", + "ipAddress": "10.42.0.1", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 14.465784284662089 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 105, + "input": { + "date": "2015-06-15T08:30:00", + "username": "tdavis", + "ipAddress": "10.42.0.1", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 34.88024499631731 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 106, + "input": { + "date": "2015-06-15T08:30:00", + "username": "nrhine", + "ipAddress": "10.24.5.10", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 10.0 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 107, + "input": { + "date": "2015-06-15T08:30:00", + "username": "jsmith", + "ipAddress": "10.24.5.10", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 27.94867642699313 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 108, + "input": { + "date": "2015-06-15T08:30:00", + "username": "alee", + "ipAddress": "10.24.5.10", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 20.982892142331043 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 109, + "input": { + "date": "2015-06-15T08:30:00", + "username": "mchen", + "ipAddress": "10.24.5.10", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 24.465784284662085 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 110, + "input": { + "date": "2015-06-15T08:30:00", + "username": "kpatel", + "ipAddress": "10.24.5.10", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 44.8802449963173 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 111, + "input": { + "date": "2015-06-15T08:30:00", + "username": "bwong", + "ipAddress": "10.26.8.20", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 10.0 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 112, + "input": { + "date": "2015-06-15T08:30:00", + "username": "tdavis", + "ipAddress": "10.26.8.20", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 27.94867642699313 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 113, + "input": { + "date": "2015-06-15T08:30:00", + "username": "nrhine", + "ipAddress": "10.26.8.20", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 20.982892142331043 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 114, + "input": { + "date": "2015-06-15T08:30:00", + "username": "jsmith", + "ipAddress": "10.26.8.20", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 24.465784284662085 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 115, + "input": { + "date": "2015-06-15T08:30:00", + "username": "alee", + "ipAddress": "10.26.8.20", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 44.8802449963173 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 116, + "input": { + "date": "2015-06-15T08:30:00", + "username": "mchen", + "ipAddress": "172.16.100.1", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 10.0 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 117, + "input": { + "date": "2015-06-15T08:30:00", + "username": "kpatel", + "ipAddress": "172.16.100.1", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 27.94867642699313 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 118, + "input": { + "date": "2015-06-15T08:30:00", + "username": "bwong", + "ipAddress": "172.16.100.1", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 20.982892142331043 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 119, + "input": { + "date": "2015-06-15T08:30:00", + "username": "tdavis", + "ipAddress": "172.16.100.1", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 24.465784284662085 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 120, + "input": { + "date": "2015-06-15T08:30:00", + "username": "nrhine", + "ipAddress": "172.16.100.1", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 44.8802449963173 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 121, + "input": { + "date": "2015-06-15T08:30:00", + "username": "jsmith", + "ipAddress": "8.8.8.8", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 50.0 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 122, + "input": { + "date": "2015-06-15T08:30:00", + "username": "alee", + "ipAddress": "8.8.8.8", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 67.94867642699313 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 123, + "input": { + "date": "2015-06-15T08:30:00", + "username": "mchen", + "ipAddress": "8.8.8.8", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 60.982892142331046 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 124, + "input": { + "date": "2015-06-15T08:30:00", + "username": "kpatel", + "ipAddress": "8.8.8.8", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 64.46578428466208 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 125, + "input": { + "date": "2015-06-15T08:30:00", + "username": "bwong", + "ipAddress": "8.8.8.8", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 84.8802449963173 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 126, + "input": { + "date": "2015-06-15T08:30:00", + "username": "tdavis", + "ipAddress": "1.2.3.4", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 50.0 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 127, + "input": { + "date": "2015-06-15T08:30:00", + "username": "nrhine", + "ipAddress": "1.2.3.4", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 67.94867642699313 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 128, + "input": { + "date": "2015-06-15T08:30:00", + "username": "jsmith", + "ipAddress": "1.2.3.4", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 60.982892142331046 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 129, + "input": { + "date": "2015-06-15T08:30:00", + "username": "alee", + "ipAddress": "1.2.3.4", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 64.46578428466208 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 130, + "input": { + "date": "2015-06-15T08:30:00", + "username": "mchen", + "ipAddress": "1.2.3.4", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 84.8802449963173 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 131, + "input": { + "date": "2015-06-15T08:30:00", + "username": "kpatel", + "ipAddress": "203.0.113.50", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 50.0 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 132, + "input": { + "date": "2015-06-15T08:30:00", + "username": "bwong", + "ipAddress": "203.0.113.50", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 67.94867642699313 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 133, + "input": { + "date": "2015-06-15T08:30:00", + "username": "tdavis", + "ipAddress": "203.0.113.50", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 60.982892142331046 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 134, + "input": { + "date": "2015-06-15T08:30:00", + "username": "nrhine", + "ipAddress": "203.0.113.50", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 64.46578428466208 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 135, + "input": { + "date": "2015-06-15T08:30:00", + "username": "jsmith", + "ipAddress": "203.0.113.50", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 84.8802449963173 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 136, + "input": { + "date": "2015-06-15T08:30:00", + "username": "alee", + "ipAddress": "10.42.10.5", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 35.0 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 137, + "input": { + "date": "2015-06-15T08:30:00", + "username": "mchen", + "ipAddress": "10.42.10.5", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 52.94867642699313 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 138, + "input": { + "date": "2015-06-15T08:30:00", + "username": "kpatel", + "ipAddress": "10.42.10.5", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 45.982892142331046 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 139, + "input": { + "date": "2015-06-15T08:30:00", + "username": "bwong", + "ipAddress": "10.42.10.5", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 49.465784284662085 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 140, + "input": { + "date": "2015-06-15T08:30:00", + "username": "tdavis", + "ipAddress": "10.42.10.5", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 69.8802449963173 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 141, + "input": { + "date": "2015-06-15T08:30:00", + "username": "nrhine", + "ipAddress": "10.42.0.1", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 35.0 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 142, + "input": { + "date": "2015-06-15T08:30:00", + "username": "jsmith", + "ipAddress": "10.42.0.1", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 52.94867642699313 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 143, + "input": { + "date": "2015-06-15T08:30:00", + "username": "alee", + "ipAddress": "10.42.0.1", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 45.982892142331046 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 144, + "input": { + "date": "2015-06-15T08:30:00", + "username": "mchen", + "ipAddress": "10.42.0.1", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 49.465784284662085 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 145, + "input": { + "date": "2015-06-15T08:30:00", + "username": "kpatel", + "ipAddress": "10.42.0.1", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 69.8802449963173 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 146, + "input": { + "date": "2015-06-15T08:30:00", + "username": "bwong", + "ipAddress": "10.24.5.10", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 45.0 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 147, + "input": { + "date": "2015-06-15T08:30:00", + "username": "tdavis", + "ipAddress": "10.24.5.10", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 62.94867642699313 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 148, + "input": { + "date": "2015-06-15T08:30:00", + "username": "nrhine", + "ipAddress": "10.24.5.10", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 55.982892142331046 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 149, + "input": { + "date": "2015-06-15T08:30:00", + "username": "jsmith", + "ipAddress": "10.24.5.10", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 59.465784284662085 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 150, + "input": { + "date": "2015-06-15T08:30:00", + "username": "alee", + "ipAddress": "10.24.5.10", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 79.8802449963173 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 151, + "input": { + "date": "2015-06-15T08:30:00", + "username": "mchen", + "ipAddress": "10.26.8.20", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 45.0 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 152, + "input": { + "date": "2015-06-15T08:30:00", + "username": "kpatel", + "ipAddress": "10.26.8.20", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 62.94867642699313 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 153, + "input": { + "date": "2015-06-15T08:30:00", + "username": "bwong", + "ipAddress": "10.26.8.20", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 55.982892142331046 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 154, + "input": { + "date": "2015-06-15T08:30:00", + "username": "tdavis", + "ipAddress": "10.26.8.20", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 59.465784284662085 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 155, + "input": { + "date": "2015-06-15T08:30:00", + "username": "nrhine", + "ipAddress": "10.26.8.20", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 79.8802449963173 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 156, + "input": { + "date": "2015-06-15T08:30:00", + "username": "jsmith", + "ipAddress": "172.16.100.1", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 45.0 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 157, + "input": { + "date": "2015-06-15T08:30:00", + "username": "alee", + "ipAddress": "172.16.100.1", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 62.94867642699313 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 158, + "input": { + "date": "2015-06-15T08:30:00", + "username": "mchen", + "ipAddress": "172.16.100.1", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 55.982892142331046 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 159, + "input": { + "date": "2015-06-15T08:30:00", + "username": "kpatel", + "ipAddress": "172.16.100.1", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 59.465784284662085 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 160, + "input": { + "date": "2015-06-15T08:30:00", + "username": "bwong", + "ipAddress": "172.16.100.1", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 79.8802449963173 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 161, + "input": { + "date": "2015-06-15T12:30:00", + "username": "tdavis", + "ipAddress": "8.8.8.8", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 15.0 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 162, + "input": { + "date": "2015-06-15T12:30:00", + "username": "nrhine", + "ipAddress": "8.8.8.8", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 32.94867642699313 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 163, + "input": { + "date": "2015-06-15T12:30:00", + "username": "jsmith", + "ipAddress": "8.8.8.8", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 25.982892142331043 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 164, + "input": { + "date": "2015-06-15T12:30:00", + "username": "alee", + "ipAddress": "8.8.8.8", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 29.465784284662085 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 165, + "input": { + "date": "2015-06-15T12:30:00", + "username": "mchen", + "ipAddress": "8.8.8.8", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 49.8802449963173 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 166, + "input": { + "date": "2015-06-15T12:30:00", + "username": "kpatel", + "ipAddress": "1.2.3.4", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 15.0 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 167, + "input": { + "date": "2015-06-15T12:30:00", + "username": "bwong", + "ipAddress": "1.2.3.4", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 32.94867642699313 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 168, + "input": { + "date": "2015-06-15T12:30:00", + "username": "tdavis", + "ipAddress": "1.2.3.4", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 25.982892142331043 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 169, + "input": { + "date": "2015-06-15T12:30:00", + "username": "nrhine", + "ipAddress": "1.2.3.4", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 29.465784284662085 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 170, + "input": { + "date": "2015-06-15T12:30:00", + "username": "jsmith", + "ipAddress": "1.2.3.4", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 49.8802449963173 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 171, + "input": { + "date": "2015-06-15T12:30:00", + "username": "alee", + "ipAddress": "203.0.113.50", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 15.0 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 172, + "input": { + "date": "2015-06-15T12:30:00", + "username": "mchen", + "ipAddress": "203.0.113.50", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 32.94867642699313 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 173, + "input": { + "date": "2015-06-15T12:30:00", + "username": "kpatel", + "ipAddress": "203.0.113.50", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 25.982892142331043 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 174, + "input": { + "date": "2015-06-15T12:30:00", + "username": "bwong", + "ipAddress": "203.0.113.50", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 29.465784284662085 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 175, + "input": { + "date": "2015-06-15T12:30:00", + "username": "tdavis", + "ipAddress": "203.0.113.50", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 49.8802449963173 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 176, + "input": { + "date": "2015-06-15T12:30:00", + "username": "nrhine", + "ipAddress": "10.42.10.5", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 0.0 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 177, + "input": { + "date": "2015-06-15T12:30:00", + "username": "jsmith", + "ipAddress": "10.42.10.5", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 17.94867642699313 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 178, + "input": { + "date": "2015-06-15T12:30:00", + "username": "alee", + "ipAddress": "10.42.10.5", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 10.982892142331043 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 179, + "input": { + "date": "2015-06-15T12:30:00", + "username": "mchen", + "ipAddress": "10.42.10.5", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 14.465784284662089 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 180, + "input": { + "date": "2015-06-15T12:30:00", + "username": "kpatel", + "ipAddress": "10.42.10.5", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 34.88024499631731 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 181, + "input": { + "date": "2015-06-15T12:30:00", + "username": "bwong", + "ipAddress": "10.42.0.1", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 0.0 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 182, + "input": { + "date": "2015-06-15T12:30:00", + "username": "tdavis", + "ipAddress": "10.42.0.1", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 17.94867642699313 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 183, + "input": { + "date": "2015-06-15T12:30:00", + "username": "nrhine", + "ipAddress": "10.42.0.1", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 10.982892142331043 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 184, + "input": { + "date": "2015-06-15T12:30:00", + "username": "jsmith", + "ipAddress": "10.42.0.1", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 14.465784284662089 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 185, + "input": { + "date": "2015-06-15T12:30:00", + "username": "alee", + "ipAddress": "10.42.0.1", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 34.88024499631731 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 186, + "input": { + "date": "2015-06-15T12:30:00", + "username": "mchen", + "ipAddress": "10.24.5.10", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 10.0 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 187, + "input": { + "date": "2015-06-15T12:30:00", + "username": "kpatel", + "ipAddress": "10.24.5.10", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 27.94867642699313 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 188, + "input": { + "date": "2015-06-15T12:30:00", + "username": "bwong", + "ipAddress": "10.24.5.10", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 20.982892142331043 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 189, + "input": { + "date": "2015-06-15T12:30:00", + "username": "tdavis", + "ipAddress": "10.24.5.10", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 24.465784284662085 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 190, + "input": { + "date": "2015-06-15T12:30:00", + "username": "nrhine", + "ipAddress": "10.24.5.10", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 44.8802449963173 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 191, + "input": { + "date": "2015-06-15T12:30:00", + "username": "jsmith", + "ipAddress": "10.26.8.20", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 10.0 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 192, + "input": { + "date": "2015-06-15T12:30:00", + "username": "alee", + "ipAddress": "10.26.8.20", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 27.94867642699313 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 193, + "input": { + "date": "2015-06-15T12:30:00", + "username": "mchen", + "ipAddress": "10.26.8.20", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 20.982892142331043 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 194, + "input": { + "date": "2015-06-15T12:30:00", + "username": "kpatel", + "ipAddress": "10.26.8.20", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 24.465784284662085 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 195, + "input": { + "date": "2015-06-15T12:30:00", + "username": "bwong", + "ipAddress": "10.26.8.20", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 44.8802449963173 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 196, + "input": { + "date": "2015-06-15T12:30:00", + "username": "tdavis", + "ipAddress": "172.16.100.1", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 10.0 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 197, + "input": { + "date": "2015-06-15T12:30:00", + "username": "nrhine", + "ipAddress": "172.16.100.1", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 27.94867642699313 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 198, + "input": { + "date": "2015-06-15T12:30:00", + "username": "jsmith", + "ipAddress": "172.16.100.1", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 20.982892142331043 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 199, + "input": { + "date": "2015-06-15T12:30:00", + "username": "alee", + "ipAddress": "172.16.100.1", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 24.465784284662085 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 200, + "input": { + "date": "2015-06-15T12:30:00", + "username": "mchen", + "ipAddress": "172.16.100.1", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 44.8802449963173 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 201, + "input": { + "date": "2015-06-15T12:30:00", + "username": "kpatel", + "ipAddress": "8.8.8.8", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 50.0 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 202, + "input": { + "date": "2015-06-15T12:30:00", + "username": "bwong", + "ipAddress": "8.8.8.8", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 67.94867642699313 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 203, + "input": { + "date": "2015-06-15T12:30:00", + "username": "tdavis", + "ipAddress": "8.8.8.8", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 60.982892142331046 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 204, + "input": { + "date": "2015-06-15T12:30:00", + "username": "nrhine", + "ipAddress": "8.8.8.8", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 64.46578428466208 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 205, + "input": { + "date": "2015-06-15T12:30:00", + "username": "jsmith", + "ipAddress": "8.8.8.8", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 84.8802449963173 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 206, + "input": { + "date": "2015-06-15T12:30:00", + "username": "alee", + "ipAddress": "1.2.3.4", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 50.0 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 207, + "input": { + "date": "2015-06-15T12:30:00", + "username": "mchen", + "ipAddress": "1.2.3.4", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 67.94867642699313 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 208, + "input": { + "date": "2015-06-15T12:30:00", + "username": "kpatel", + "ipAddress": "1.2.3.4", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 60.982892142331046 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 209, + "input": { + "date": "2015-06-15T12:30:00", + "username": "bwong", + "ipAddress": "1.2.3.4", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 64.46578428466208 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 210, + "input": { + "date": "2015-06-15T12:30:00", + "username": "tdavis", + "ipAddress": "1.2.3.4", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 84.8802449963173 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 211, + "input": { + "date": "2015-06-15T12:30:00", + "username": "nrhine", + "ipAddress": "203.0.113.50", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 50.0 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 212, + "input": { + "date": "2015-06-15T12:30:00", + "username": "jsmith", + "ipAddress": "203.0.113.50", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 67.94867642699313 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 213, + "input": { + "date": "2015-06-15T12:30:00", + "username": "alee", + "ipAddress": "203.0.113.50", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 60.982892142331046 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 214, + "input": { + "date": "2015-06-15T12:30:00", + "username": "mchen", + "ipAddress": "203.0.113.50", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 64.46578428466208 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 215, + "input": { + "date": "2015-06-15T12:30:00", + "username": "kpatel", + "ipAddress": "203.0.113.50", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 84.8802449963173 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 216, + "input": { + "date": "2015-06-15T12:30:00", + "username": "bwong", + "ipAddress": "10.42.10.5", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 35.0 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 217, + "input": { + "date": "2015-06-15T12:30:00", + "username": "tdavis", + "ipAddress": "10.42.10.5", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 52.94867642699313 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 218, + "input": { + "date": "2015-06-15T12:30:00", + "username": "nrhine", + "ipAddress": "10.42.10.5", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 45.982892142331046 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 219, + "input": { + "date": "2015-06-15T12:30:00", + "username": "jsmith", + "ipAddress": "10.42.10.5", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 49.465784284662085 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 220, + "input": { + "date": "2015-06-15T12:30:00", + "username": "alee", + "ipAddress": "10.42.10.5", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 69.8802449963173 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 221, + "input": { + "date": "2015-06-15T12:30:00", + "username": "mchen", + "ipAddress": "10.42.0.1", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 35.0 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 222, + "input": { + "date": "2015-06-15T12:30:00", + "username": "kpatel", + "ipAddress": "10.42.0.1", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 52.94867642699313 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 223, + "input": { + "date": "2015-06-15T12:30:00", + "username": "bwong", + "ipAddress": "10.42.0.1", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 45.982892142331046 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 224, + "input": { + "date": "2015-06-15T12:30:00", + "username": "tdavis", + "ipAddress": "10.42.0.1", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 49.465784284662085 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 225, + "input": { + "date": "2015-06-15T12:30:00", + "username": "nrhine", + "ipAddress": "10.42.0.1", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 69.8802449963173 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 226, + "input": { + "date": "2015-06-15T12:30:00", + "username": "jsmith", + "ipAddress": "10.24.5.10", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 45.0 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 227, + "input": { + "date": "2015-06-15T12:30:00", + "username": "alee", + "ipAddress": "10.24.5.10", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 62.94867642699313 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 228, + "input": { + "date": "2015-06-15T12:30:00", + "username": "mchen", + "ipAddress": "10.24.5.10", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 55.982892142331046 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 229, + "input": { + "date": "2015-06-15T12:30:00", + "username": "kpatel", + "ipAddress": "10.24.5.10", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 59.465784284662085 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 230, + "input": { + "date": "2015-06-15T12:30:00", + "username": "bwong", + "ipAddress": "10.24.5.10", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 79.8802449963173 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 231, + "input": { + "date": "2015-06-15T12:30:00", + "username": "tdavis", + "ipAddress": "10.26.8.20", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 45.0 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 232, + "input": { + "date": "2015-06-15T12:30:00", + "username": "nrhine", + "ipAddress": "10.26.8.20", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 62.94867642699313 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 233, + "input": { + "date": "2015-06-15T12:30:00", + "username": "jsmith", + "ipAddress": "10.26.8.20", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 55.982892142331046 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 234, + "input": { + "date": "2015-06-15T12:30:00", + "username": "alee", + "ipAddress": "10.26.8.20", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 59.465784284662085 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 235, + "input": { + "date": "2015-06-15T12:30:00", + "username": "mchen", + "ipAddress": "10.26.8.20", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 79.8802449963173 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 236, + "input": { + "date": "2015-06-15T12:30:00", + "username": "kpatel", + "ipAddress": "172.16.100.1", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 45.0 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 237, + "input": { + "date": "2015-06-15T12:30:00", + "username": "bwong", + "ipAddress": "172.16.100.1", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 62.94867642699313 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 238, + "input": { + "date": "2015-06-15T12:30:00", + "username": "tdavis", + "ipAddress": "172.16.100.1", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 55.982892142331046 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 239, + "input": { + "date": "2015-06-15T12:30:00", + "username": "nrhine", + "ipAddress": "172.16.100.1", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 59.465784284662085 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 240, + "input": { + "date": "2015-06-15T12:30:00", + "username": "jsmith", + "ipAddress": "172.16.100.1", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 79.8802449963173 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 241, + "input": { + "date": "2015-06-15T16:30:00", + "username": "alee", + "ipAddress": "8.8.8.8", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 15.0 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 242, + "input": { + "date": "2015-06-15T16:30:00", + "username": "mchen", + "ipAddress": "8.8.8.8", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 32.94867642699313 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 243, + "input": { + "date": "2015-06-15T16:30:00", + "username": "kpatel", + "ipAddress": "8.8.8.8", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 25.982892142331043 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 244, + "input": { + "date": "2015-06-15T16:30:00", + "username": "bwong", + "ipAddress": "8.8.8.8", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 29.465784284662085 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 245, + "input": { + "date": "2015-06-15T16:30:00", + "username": "tdavis", + "ipAddress": "8.8.8.8", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 49.8802449963173 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 246, + "input": { + "date": "2015-06-15T16:30:00", + "username": "nrhine", + "ipAddress": "1.2.3.4", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 15.0 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 247, + "input": { + "date": "2015-06-15T16:30:00", + "username": "jsmith", + "ipAddress": "1.2.3.4", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 32.94867642699313 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 248, + "input": { + "date": "2015-06-15T16:30:00", + "username": "alee", + "ipAddress": "1.2.3.4", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 25.982892142331043 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 249, + "input": { + "date": "2015-06-15T16:30:00", + "username": "mchen", + "ipAddress": "1.2.3.4", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 29.465784284662085 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 250, + "input": { + "date": "2015-06-15T16:30:00", + "username": "kpatel", + "ipAddress": "1.2.3.4", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 49.8802449963173 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 251, + "input": { + "date": "2015-06-15T16:30:00", + "username": "bwong", + "ipAddress": "203.0.113.50", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 15.0 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 252, + "input": { + "date": "2015-06-15T16:30:00", + "username": "tdavis", + "ipAddress": "203.0.113.50", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 32.94867642699313 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 253, + "input": { + "date": "2015-06-15T16:30:00", + "username": "nrhine", + "ipAddress": "203.0.113.50", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 25.982892142331043 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 254, + "input": { + "date": "2015-06-15T16:30:00", + "username": "jsmith", + "ipAddress": "203.0.113.50", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 29.465784284662085 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 255, + "input": { + "date": "2015-06-15T16:30:00", + "username": "alee", + "ipAddress": "203.0.113.50", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 49.8802449963173 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 256, + "input": { + "date": "2015-06-15T16:30:00", + "username": "mchen", + "ipAddress": "10.42.10.5", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 0.0 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 257, + "input": { + "date": "2015-06-15T16:30:00", + "username": "kpatel", + "ipAddress": "10.42.10.5", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 17.94867642699313 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 258, + "input": { + "date": "2015-06-15T16:30:00", + "username": "bwong", + "ipAddress": "10.42.10.5", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 10.982892142331043 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 259, + "input": { + "date": "2015-06-15T16:30:00", + "username": "tdavis", + "ipAddress": "10.42.10.5", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 14.465784284662089 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 260, + "input": { + "date": "2015-06-15T16:30:00", + "username": "nrhine", + "ipAddress": "10.42.10.5", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 34.88024499631731 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 261, + "input": { + "date": "2015-06-15T16:30:00", + "username": "jsmith", + "ipAddress": "10.42.0.1", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 0.0 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 262, + "input": { + "date": "2015-06-15T16:30:00", + "username": "alee", + "ipAddress": "10.42.0.1", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 17.94867642699313 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 263, + "input": { + "date": "2015-06-15T16:30:00", + "username": "mchen", + "ipAddress": "10.42.0.1", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 10.982892142331043 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 264, + "input": { + "date": "2015-06-15T16:30:00", + "username": "kpatel", + "ipAddress": "10.42.0.1", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 14.465784284662089 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 265, + "input": { + "date": "2015-06-15T16:30:00", + "username": "bwong", + "ipAddress": "10.42.0.1", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 34.88024499631731 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 266, + "input": { + "date": "2015-06-15T16:30:00", + "username": "tdavis", + "ipAddress": "10.24.5.10", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 10.0 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 267, + "input": { + "date": "2015-06-15T16:30:00", + "username": "nrhine", + "ipAddress": "10.24.5.10", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 27.94867642699313 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 268, + "input": { + "date": "2015-06-15T16:30:00", + "username": "jsmith", + "ipAddress": "10.24.5.10", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 20.982892142331043 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 269, + "input": { + "date": "2015-06-15T16:30:00", + "username": "alee", + "ipAddress": "10.24.5.10", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 24.465784284662085 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 270, + "input": { + "date": "2015-06-15T16:30:00", + "username": "mchen", + "ipAddress": "10.24.5.10", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 44.8802449963173 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 271, + "input": { + "date": "2015-06-15T16:30:00", + "username": "kpatel", + "ipAddress": "10.26.8.20", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 10.0 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 272, + "input": { + "date": "2015-06-15T16:30:00", + "username": "bwong", + "ipAddress": "10.26.8.20", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 27.94867642699313 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 273, + "input": { + "date": "2015-06-15T16:30:00", + "username": "tdavis", + "ipAddress": "10.26.8.20", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 20.982892142331043 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 274, + "input": { + "date": "2015-06-15T16:30:00", + "username": "nrhine", + "ipAddress": "10.26.8.20", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 24.465784284662085 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 275, + "input": { + "date": "2015-06-15T16:30:00", + "username": "jsmith", + "ipAddress": "10.26.8.20", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 44.8802449963173 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 276, + "input": { + "date": "2015-06-15T16:30:00", + "username": "alee", + "ipAddress": "172.16.100.1", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 10.0 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 277, + "input": { + "date": "2015-06-15T16:30:00", + "username": "mchen", + "ipAddress": "172.16.100.1", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 27.94867642699313 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 278, + "input": { + "date": "2015-06-15T16:30:00", + "username": "kpatel", + "ipAddress": "172.16.100.1", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 20.982892142331043 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 279, + "input": { + "date": "2015-06-15T16:30:00", + "username": "bwong", + "ipAddress": "172.16.100.1", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 24.465784284662085 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 280, + "input": { + "date": "2015-06-15T16:30:00", + "username": "tdavis", + "ipAddress": "172.16.100.1", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 44.8802449963173 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 281, + "input": { + "date": "2015-06-15T16:30:00", + "username": "nrhine", + "ipAddress": "8.8.8.8", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 50.0 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 282, + "input": { + "date": "2015-06-15T16:30:00", + "username": "jsmith", + "ipAddress": "8.8.8.8", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 67.94867642699313 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 283, + "input": { + "date": "2015-06-15T16:30:00", + "username": "alee", + "ipAddress": "8.8.8.8", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 60.982892142331046 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 284, + "input": { + "date": "2015-06-15T16:30:00", + "username": "mchen", + "ipAddress": "8.8.8.8", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 64.46578428466208 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 285, + "input": { + "date": "2015-06-15T16:30:00", + "username": "kpatel", + "ipAddress": "8.8.8.8", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 84.8802449963173 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 286, + "input": { + "date": "2015-06-15T16:30:00", + "username": "bwong", + "ipAddress": "1.2.3.4", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 50.0 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 287, + "input": { + "date": "2015-06-15T16:30:00", + "username": "tdavis", + "ipAddress": "1.2.3.4", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 67.94867642699313 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 288, + "input": { + "date": "2015-06-15T16:30:00", + "username": "nrhine", + "ipAddress": "1.2.3.4", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 60.982892142331046 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 289, + "input": { + "date": "2015-06-15T16:30:00", + "username": "jsmith", + "ipAddress": "1.2.3.4", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 64.46578428466208 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 290, + "input": { + "date": "2015-06-15T16:30:00", + "username": "alee", + "ipAddress": "1.2.3.4", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 84.8802449963173 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 291, + "input": { + "date": "2015-06-15T16:30:00", + "username": "mchen", + "ipAddress": "203.0.113.50", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 50.0 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 292, + "input": { + "date": "2015-06-15T16:30:00", + "username": "kpatel", + "ipAddress": "203.0.113.50", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 67.94867642699313 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 293, + "input": { + "date": "2015-06-15T16:30:00", + "username": "bwong", + "ipAddress": "203.0.113.50", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 60.982892142331046 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 294, + "input": { + "date": "2015-06-15T16:30:00", + "username": "tdavis", + "ipAddress": "203.0.113.50", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 64.46578428466208 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 295, + "input": { + "date": "2015-06-15T16:30:00", + "username": "nrhine", + "ipAddress": "203.0.113.50", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 84.8802449963173 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 296, + "input": { + "date": "2015-06-15T16:30:00", + "username": "jsmith", + "ipAddress": "10.42.10.5", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 35.0 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 297, + "input": { + "date": "2015-06-15T16:30:00", + "username": "alee", + "ipAddress": "10.42.10.5", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 52.94867642699313 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 298, + "input": { + "date": "2015-06-15T16:30:00", + "username": "mchen", + "ipAddress": "10.42.10.5", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 45.982892142331046 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 299, + "input": { + "date": "2015-06-15T16:30:00", + "username": "kpatel", + "ipAddress": "10.42.10.5", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 49.465784284662085 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 300, + "input": { + "date": "2015-06-15T16:30:00", + "username": "bwong", + "ipAddress": "10.42.10.5", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 69.8802449963173 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 301, + "input": { + "date": "2015-06-15T16:30:00", + "username": "tdavis", + "ipAddress": "10.42.0.1", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 35.0 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 302, + "input": { + "date": "2015-06-15T16:30:00", + "username": "nrhine", + "ipAddress": "10.42.0.1", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 52.94867642699313 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 303, + "input": { + "date": "2015-06-15T16:30:00", + "username": "jsmith", + "ipAddress": "10.42.0.1", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 45.982892142331046 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 304, + "input": { + "date": "2015-06-15T16:30:00", + "username": "alee", + "ipAddress": "10.42.0.1", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 49.465784284662085 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 305, + "input": { + "date": "2015-06-15T16:30:00", + "username": "mchen", + "ipAddress": "10.42.0.1", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 69.8802449963173 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 306, + "input": { + "date": "2015-06-15T16:30:00", + "username": "kpatel", + "ipAddress": "10.24.5.10", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 45.0 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 307, + "input": { + "date": "2015-06-15T16:30:00", + "username": "bwong", + "ipAddress": "10.24.5.10", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 62.94867642699313 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 308, + "input": { + "date": "2015-06-15T16:30:00", + "username": "tdavis", + "ipAddress": "10.24.5.10", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 55.982892142331046 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 309, + "input": { + "date": "2015-06-15T16:30:00", + "username": "nrhine", + "ipAddress": "10.24.5.10", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 59.465784284662085 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 310, + "input": { + "date": "2015-06-15T16:30:00", + "username": "jsmith", + "ipAddress": "10.24.5.10", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 79.8802449963173 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 311, + "input": { + "date": "2015-06-15T16:30:00", + "username": "alee", + "ipAddress": "10.26.8.20", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 45.0 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 312, + "input": { + "date": "2015-06-15T16:30:00", + "username": "mchen", + "ipAddress": "10.26.8.20", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 62.94867642699313 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 313, + "input": { + "date": "2015-06-15T16:30:00", + "username": "kpatel", + "ipAddress": "10.26.8.20", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 55.982892142331046 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 314, + "input": { + "date": "2015-06-15T16:30:00", + "username": "bwong", + "ipAddress": "10.26.8.20", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 59.465784284662085 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 315, + "input": { + "date": "2015-06-15T16:30:00", + "username": "tdavis", + "ipAddress": "10.26.8.20", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 79.8802449963173 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 316, + "input": { + "date": "2015-06-15T16:30:00", + "username": "nrhine", + "ipAddress": "172.16.100.1", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 45.0 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 317, + "input": { + "date": "2015-06-15T16:30:00", + "username": "jsmith", + "ipAddress": "172.16.100.1", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 62.94867642699313 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 318, + "input": { + "date": "2015-06-15T16:30:00", + "username": "alee", + "ipAddress": "172.16.100.1", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 55.982892142331046 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 319, + "input": { + "date": "2015-06-15T16:30:00", + "username": "mchen", + "ipAddress": "172.16.100.1", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 59.465784284662085 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 320, + "input": { + "date": "2015-06-15T16:30:00", + "username": "kpatel", + "ipAddress": "172.16.100.1", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 79.8802449963173 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 321, + "input": { + "date": "2015-06-15T20:30:00", + "username": "bwong", + "ipAddress": "8.8.8.8", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 15.0 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 322, + "input": { + "date": "2015-06-15T20:30:00", + "username": "tdavis", + "ipAddress": "8.8.8.8", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 32.94867642699313 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 323, + "input": { + "date": "2015-06-15T20:30:00", + "username": "nrhine", + "ipAddress": "8.8.8.8", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 25.982892142331043 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 324, + "input": { + "date": "2015-06-15T20:30:00", + "username": "jsmith", + "ipAddress": "8.8.8.8", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 29.465784284662085 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 325, + "input": { + "date": "2015-06-15T20:30:00", + "username": "alee", + "ipAddress": "8.8.8.8", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 49.8802449963173 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 326, + "input": { + "date": "2015-06-15T20:30:00", + "username": "mchen", + "ipAddress": "1.2.3.4", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 15.0 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 327, + "input": { + "date": "2015-06-15T20:30:00", + "username": "kpatel", + "ipAddress": "1.2.3.4", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 32.94867642699313 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 328, + "input": { + "date": "2015-06-15T20:30:00", + "username": "bwong", + "ipAddress": "1.2.3.4", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 25.982892142331043 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 329, + "input": { + "date": "2015-06-15T20:30:00", + "username": "tdavis", + "ipAddress": "1.2.3.4", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 29.465784284662085 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 330, + "input": { + "date": "2015-06-15T20:30:00", + "username": "nrhine", + "ipAddress": "1.2.3.4", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 49.8802449963173 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 331, + "input": { + "date": "2015-06-15T20:30:00", + "username": "jsmith", + "ipAddress": "203.0.113.50", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 15.0 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 332, + "input": { + "date": "2015-06-15T20:30:00", + "username": "alee", + "ipAddress": "203.0.113.50", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 32.94867642699313 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 333, + "input": { + "date": "2015-06-15T20:30:00", + "username": "mchen", + "ipAddress": "203.0.113.50", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 25.982892142331043 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 334, + "input": { + "date": "2015-06-15T20:30:00", + "username": "kpatel", + "ipAddress": "203.0.113.50", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 29.465784284662085 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 335, + "input": { + "date": "2015-06-15T20:30:00", + "username": "bwong", + "ipAddress": "203.0.113.50", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 49.8802449963173 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 336, + "input": { + "date": "2015-06-15T20:30:00", + "username": "tdavis", + "ipAddress": "10.42.10.5", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 0.0 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 337, + "input": { + "date": "2015-06-15T20:30:00", + "username": "nrhine", + "ipAddress": "10.42.10.5", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 17.94867642699313 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 338, + "input": { + "date": "2015-06-15T20:30:00", + "username": "jsmith", + "ipAddress": "10.42.10.5", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 10.982892142331043 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 339, + "input": { + "date": "2015-06-15T20:30:00", + "username": "alee", + "ipAddress": "10.42.10.5", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 14.465784284662089 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 340, + "input": { + "date": "2015-06-15T20:30:00", + "username": "mchen", + "ipAddress": "10.42.10.5", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 34.88024499631731 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 341, + "input": { + "date": "2015-06-15T20:30:00", + "username": "kpatel", + "ipAddress": "10.42.0.1", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 0.0 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 342, + "input": { + "date": "2015-06-15T20:30:00", + "username": "bwong", + "ipAddress": "10.42.0.1", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 17.94867642699313 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 343, + "input": { + "date": "2015-06-15T20:30:00", + "username": "tdavis", + "ipAddress": "10.42.0.1", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 10.982892142331043 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 344, + "input": { + "date": "2015-06-15T20:30:00", + "username": "nrhine", + "ipAddress": "10.42.0.1", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 14.465784284662089 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 345, + "input": { + "date": "2015-06-15T20:30:00", + "username": "jsmith", + "ipAddress": "10.42.0.1", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 34.88024499631731 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 346, + "input": { + "date": "2015-06-15T20:30:00", + "username": "alee", + "ipAddress": "10.24.5.10", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 10.0 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 347, + "input": { + "date": "2015-06-15T20:30:00", + "username": "mchen", + "ipAddress": "10.24.5.10", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 27.94867642699313 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 348, + "input": { + "date": "2015-06-15T20:30:00", + "username": "kpatel", + "ipAddress": "10.24.5.10", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 20.982892142331043 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 349, + "input": { + "date": "2015-06-15T20:30:00", + "username": "bwong", + "ipAddress": "10.24.5.10", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 24.465784284662085 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 350, + "input": { + "date": "2015-06-15T20:30:00", + "username": "tdavis", + "ipAddress": "10.24.5.10", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 44.8802449963173 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 351, + "input": { + "date": "2015-06-15T20:30:00", + "username": "nrhine", + "ipAddress": "10.26.8.20", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 10.0 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 352, + "input": { + "date": "2015-06-15T20:30:00", + "username": "jsmith", + "ipAddress": "10.26.8.20", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 27.94867642699313 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 353, + "input": { + "date": "2015-06-15T20:30:00", + "username": "alee", + "ipAddress": "10.26.8.20", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 20.982892142331043 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 354, + "input": { + "date": "2015-06-15T20:30:00", + "username": "mchen", + "ipAddress": "10.26.8.20", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 24.465784284662085 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 355, + "input": { + "date": "2015-06-15T20:30:00", + "username": "kpatel", + "ipAddress": "10.26.8.20", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 44.8802449963173 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 356, + "input": { + "date": "2015-06-15T20:30:00", + "username": "bwong", + "ipAddress": "172.16.100.1", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 10.0 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 357, + "input": { + "date": "2015-06-15T20:30:00", + "username": "tdavis", + "ipAddress": "172.16.100.1", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 27.94867642699313 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 358, + "input": { + "date": "2015-06-15T20:30:00", + "username": "nrhine", + "ipAddress": "172.16.100.1", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 20.982892142331043 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 359, + "input": { + "date": "2015-06-15T20:30:00", + "username": "jsmith", + "ipAddress": "172.16.100.1", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 24.465784284662085 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 360, + "input": { + "date": "2015-06-15T20:30:00", + "username": "alee", + "ipAddress": "172.16.100.1", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 44.8802449963173 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 361, + "input": { + "date": "2015-06-15T20:30:00", + "username": "mchen", + "ipAddress": "8.8.8.8", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 50.0 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 362, + "input": { + "date": "2015-06-15T20:30:00", + "username": "kpatel", + "ipAddress": "8.8.8.8", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 67.94867642699313 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 363, + "input": { + "date": "2015-06-15T20:30:00", + "username": "bwong", + "ipAddress": "8.8.8.8", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 60.982892142331046 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 364, + "input": { + "date": "2015-06-15T20:30:00", + "username": "tdavis", + "ipAddress": "8.8.8.8", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 64.46578428466208 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 365, + "input": { + "date": "2015-06-15T20:30:00", + "username": "nrhine", + "ipAddress": "8.8.8.8", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 84.8802449963173 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 366, + "input": { + "date": "2015-06-15T20:30:00", + "username": "jsmith", + "ipAddress": "1.2.3.4", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 50.0 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 367, + "input": { + "date": "2015-06-15T20:30:00", + "username": "alee", + "ipAddress": "1.2.3.4", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 67.94867642699313 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 368, + "input": { + "date": "2015-06-15T20:30:00", + "username": "mchen", + "ipAddress": "1.2.3.4", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 60.982892142331046 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 369, + "input": { + "date": "2015-06-15T20:30:00", + "username": "kpatel", + "ipAddress": "1.2.3.4", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 64.46578428466208 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 370, + "input": { + "date": "2015-06-15T20:30:00", + "username": "bwong", + "ipAddress": "1.2.3.4", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 84.8802449963173 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 371, + "input": { + "date": "2015-06-15T20:30:00", + "username": "tdavis", + "ipAddress": "203.0.113.50", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 50.0 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 372, + "input": { + "date": "2015-06-15T20:30:00", + "username": "nrhine", + "ipAddress": "203.0.113.50", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 67.94867642699313 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 373, + "input": { + "date": "2015-06-15T20:30:00", + "username": "jsmith", + "ipAddress": "203.0.113.50", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 60.982892142331046 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 374, + "input": { + "date": "2015-06-15T20:30:00", + "username": "alee", + "ipAddress": "203.0.113.50", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 64.46578428466208 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 375, + "input": { + "date": "2015-06-15T20:30:00", + "username": "mchen", + "ipAddress": "203.0.113.50", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 84.8802449963173 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 376, + "input": { + "date": "2015-06-15T20:30:00", + "username": "kpatel", + "ipAddress": "10.42.10.5", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 35.0 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 377, + "input": { + "date": "2015-06-15T20:30:00", + "username": "bwong", + "ipAddress": "10.42.10.5", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 52.94867642699313 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 378, + "input": { + "date": "2015-06-15T20:30:00", + "username": "tdavis", + "ipAddress": "10.42.10.5", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 45.982892142331046 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 379, + "input": { + "date": "2015-06-15T20:30:00", + "username": "nrhine", + "ipAddress": "10.42.10.5", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 49.465784284662085 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 380, + "input": { + "date": "2015-06-15T20:30:00", + "username": "jsmith", + "ipAddress": "10.42.10.5", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 69.8802449963173 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 381, + "input": { + "date": "2015-06-15T20:30:00", + "username": "alee", + "ipAddress": "10.42.0.1", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 35.0 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 382, + "input": { + "date": "2015-06-15T20:30:00", + "username": "mchen", + "ipAddress": "10.42.0.1", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 52.94867642699313 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 383, + "input": { + "date": "2015-06-15T20:30:00", + "username": "kpatel", + "ipAddress": "10.42.0.1", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 45.982892142331046 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 384, + "input": { + "date": "2015-06-15T20:30:00", + "username": "bwong", + "ipAddress": "10.42.0.1", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 49.465784284662085 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 385, + "input": { + "date": "2015-06-15T20:30:00", + "username": "tdavis", + "ipAddress": "10.42.0.1", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 69.8802449963173 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 386, + "input": { + "date": "2015-06-15T20:30:00", + "username": "nrhine", + "ipAddress": "10.24.5.10", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 45.0 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 387, + "input": { + "date": "2015-06-15T20:30:00", + "username": "jsmith", + "ipAddress": "10.24.5.10", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 62.94867642699313 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 388, + "input": { + "date": "2015-06-15T20:30:00", + "username": "alee", + "ipAddress": "10.24.5.10", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 55.982892142331046 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 389, + "input": { + "date": "2015-06-15T20:30:00", + "username": "mchen", + "ipAddress": "10.24.5.10", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 59.465784284662085 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 390, + "input": { + "date": "2015-06-15T20:30:00", + "username": "kpatel", + "ipAddress": "10.24.5.10", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 79.8802449963173 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 391, + "input": { + "date": "2015-06-15T20:30:00", + "username": "bwong", + "ipAddress": "10.26.8.20", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 45.0 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 392, + "input": { + "date": "2015-06-15T20:30:00", + "username": "tdavis", + "ipAddress": "10.26.8.20", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 62.94867642699313 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 393, + "input": { + "date": "2015-06-15T20:30:00", + "username": "nrhine", + "ipAddress": "10.26.8.20", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 55.982892142331046 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 394, + "input": { + "date": "2015-06-15T20:30:00", + "username": "jsmith", + "ipAddress": "10.26.8.20", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 59.465784284662085 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 395, + "input": { + "date": "2015-06-15T20:30:00", + "username": "alee", + "ipAddress": "10.26.8.20", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 79.8802449963173 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 396, + "input": { + "date": "2015-06-15T20:30:00", + "username": "mchen", + "ipAddress": "172.16.100.1", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 45.0 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 397, + "input": { + "date": "2015-06-15T20:30:00", + "username": "kpatel", + "ipAddress": "172.16.100.1", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 62.94867642699313 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 398, + "input": { + "date": "2015-06-15T20:30:00", + "username": "bwong", + "ipAddress": "172.16.100.1", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 55.982892142331046 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 399, + "input": { + "date": "2015-06-15T20:30:00", + "username": "tdavis", + "ipAddress": "172.16.100.1", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 59.465784284662085 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 400, + "input": { + "date": "2015-06-15T20:30:00", + "username": "nrhine", + "ipAddress": "172.16.100.1", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 79.8802449963173 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 401, + "input": { + "date": "2015-06-15T23:30:00", + "username": "jsmith", + "ipAddress": "8.8.8.8", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 15.0 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 402, + "input": { + "date": "2015-06-15T23:30:00", + "username": "alee", + "ipAddress": "8.8.8.8", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 32.94867642699313 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 403, + "input": { + "date": "2015-06-15T23:30:00", + "username": "mchen", + "ipAddress": "8.8.8.8", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 25.982892142331043 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 404, + "input": { + "date": "2015-06-15T23:30:00", + "username": "kpatel", + "ipAddress": "8.8.8.8", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 29.465784284662085 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 405, + "input": { + "date": "2015-06-15T23:30:00", + "username": "bwong", + "ipAddress": "8.8.8.8", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 49.8802449963173 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 406, + "input": { + "date": "2015-06-15T23:30:00", + "username": "tdavis", + "ipAddress": "1.2.3.4", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 15.0 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 407, + "input": { + "date": "2015-06-15T23:30:00", + "username": "nrhine", + "ipAddress": "1.2.3.4", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 32.94867642699313 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 408, + "input": { + "date": "2015-06-15T23:30:00", + "username": "jsmith", + "ipAddress": "1.2.3.4", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 25.982892142331043 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 409, + "input": { + "date": "2015-06-15T23:30:00", + "username": "alee", + "ipAddress": "1.2.3.4", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 29.465784284662085 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 410, + "input": { + "date": "2015-06-15T23:30:00", + "username": "mchen", + "ipAddress": "1.2.3.4", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 49.8802449963173 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 411, + "input": { + "date": "2015-06-15T23:30:00", + "username": "kpatel", + "ipAddress": "203.0.113.50", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 15.0 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 412, + "input": { + "date": "2015-06-15T23:30:00", + "username": "bwong", + "ipAddress": "203.0.113.50", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 32.94867642699313 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 413, + "input": { + "date": "2015-06-15T23:30:00", + "username": "tdavis", + "ipAddress": "203.0.113.50", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 25.982892142331043 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 414, + "input": { + "date": "2015-06-15T23:30:00", + "username": "nrhine", + "ipAddress": "203.0.113.50", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 29.465784284662085 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 415, + "input": { + "date": "2015-06-15T23:30:00", + "username": "jsmith", + "ipAddress": "203.0.113.50", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 49.8802449963173 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 416, + "input": { + "date": "2015-06-15T23:30:00", + "username": "alee", + "ipAddress": "10.42.10.5", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 0.0 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 417, + "input": { + "date": "2015-06-15T23:30:00", + "username": "mchen", + "ipAddress": "10.42.10.5", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 17.94867642699313 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 418, + "input": { + "date": "2015-06-15T23:30:00", + "username": "kpatel", + "ipAddress": "10.42.10.5", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 10.982892142331043 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 419, + "input": { + "date": "2015-06-15T23:30:00", + "username": "bwong", + "ipAddress": "10.42.10.5", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 14.465784284662089 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 420, + "input": { + "date": "2015-06-15T23:30:00", + "username": "tdavis", + "ipAddress": "10.42.10.5", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 34.88024499631731 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 421, + "input": { + "date": "2015-06-15T23:30:00", + "username": "nrhine", + "ipAddress": "10.42.0.1", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 0.0 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 422, + "input": { + "date": "2015-06-15T23:30:00", + "username": "jsmith", + "ipAddress": "10.42.0.1", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 17.94867642699313 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 423, + "input": { + "date": "2015-06-15T23:30:00", + "username": "alee", + "ipAddress": "10.42.0.1", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 10.982892142331043 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 424, + "input": { + "date": "2015-06-15T23:30:00", + "username": "mchen", + "ipAddress": "10.42.0.1", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 14.465784284662089 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 425, + "input": { + "date": "2015-06-15T23:30:00", + "username": "kpatel", + "ipAddress": "10.42.0.1", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 34.88024499631731 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 426, + "input": { + "date": "2015-06-15T23:30:00", + "username": "bwong", + "ipAddress": "10.24.5.10", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 10.0 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 427, + "input": { + "date": "2015-06-15T23:30:00", + "username": "tdavis", + "ipAddress": "10.24.5.10", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 27.94867642699313 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 428, + "input": { + "date": "2015-06-15T23:30:00", + "username": "nrhine", + "ipAddress": "10.24.5.10", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 20.982892142331043 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 429, + "input": { + "date": "2015-06-15T23:30:00", + "username": "jsmith", + "ipAddress": "10.24.5.10", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 24.465784284662085 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 430, + "input": { + "date": "2015-06-15T23:30:00", + "username": "alee", + "ipAddress": "10.24.5.10", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 44.8802449963173 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 431, + "input": { + "date": "2015-06-15T23:30:00", + "username": "mchen", + "ipAddress": "10.26.8.20", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 10.0 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 432, + "input": { + "date": "2015-06-15T23:30:00", + "username": "kpatel", + "ipAddress": "10.26.8.20", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 27.94867642699313 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 433, + "input": { + "date": "2015-06-15T23:30:00", + "username": "bwong", + "ipAddress": "10.26.8.20", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 20.982892142331043 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 434, + "input": { + "date": "2015-06-15T23:30:00", + "username": "tdavis", + "ipAddress": "10.26.8.20", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 24.465784284662085 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 435, + "input": { + "date": "2015-06-15T23:30:00", + "username": "nrhine", + "ipAddress": "10.26.8.20", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 44.8802449963173 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 436, + "input": { + "date": "2015-06-15T23:30:00", + "username": "jsmith", + "ipAddress": "172.16.100.1", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 10.0 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 437, + "input": { + "date": "2015-06-15T23:30:00", + "username": "alee", + "ipAddress": "172.16.100.1", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 27.94867642699313 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 438, + "input": { + "date": "2015-06-15T23:30:00", + "username": "mchen", + "ipAddress": "172.16.100.1", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 20.982892142331043 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 439, + "input": { + "date": "2015-06-15T23:30:00", + "username": "kpatel", + "ipAddress": "172.16.100.1", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 24.465784284662085 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 440, + "input": { + "date": "2015-06-15T23:30:00", + "username": "bwong", + "ipAddress": "172.16.100.1", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 44.8802449963173 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 441, + "input": { + "date": "2015-06-15T23:30:00", + "username": "tdavis", + "ipAddress": "8.8.8.8", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 50.0 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 442, + "input": { + "date": "2015-06-15T23:30:00", + "username": "nrhine", + "ipAddress": "8.8.8.8", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 67.94867642699313 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 443, + "input": { + "date": "2015-06-15T23:30:00", + "username": "jsmith", + "ipAddress": "8.8.8.8", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 60.982892142331046 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 444, + "input": { + "date": "2015-06-15T23:30:00", + "username": "alee", + "ipAddress": "8.8.8.8", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 64.46578428466208 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 445, + "input": { + "date": "2015-06-15T23:30:00", + "username": "mchen", + "ipAddress": "8.8.8.8", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 84.8802449963173 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 446, + "input": { + "date": "2015-06-15T23:30:00", + "username": "kpatel", + "ipAddress": "1.2.3.4", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 50.0 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 447, + "input": { + "date": "2015-06-15T23:30:00", + "username": "bwong", + "ipAddress": "1.2.3.4", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 67.94867642699313 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 448, + "input": { + "date": "2015-06-15T23:30:00", + "username": "tdavis", + "ipAddress": "1.2.3.4", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 60.982892142331046 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 449, + "input": { + "date": "2015-06-15T23:30:00", + "username": "nrhine", + "ipAddress": "1.2.3.4", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 64.46578428466208 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 450, + "input": { + "date": "2015-06-15T23:30:00", + "username": "jsmith", + "ipAddress": "1.2.3.4", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 84.8802449963173 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 451, + "input": { + "date": "2015-06-15T23:30:00", + "username": "alee", + "ipAddress": "203.0.113.50", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 50.0 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 452, + "input": { + "date": "2015-06-15T23:30:00", + "username": "mchen", + "ipAddress": "203.0.113.50", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 67.94867642699313 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 453, + "input": { + "date": "2015-06-15T23:30:00", + "username": "kpatel", + "ipAddress": "203.0.113.50", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 60.982892142331046 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 454, + "input": { + "date": "2015-06-15T23:30:00", + "username": "bwong", + "ipAddress": "203.0.113.50", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 64.46578428466208 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 455, + "input": { + "date": "2015-06-15T23:30:00", + "username": "tdavis", + "ipAddress": "203.0.113.50", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 15, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 84.8802449963173 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 456, + "input": { + "date": "2015-06-15T23:30:00", + "username": "nrhine", + "ipAddress": "10.42.10.5", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 35.0 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 457, + "input": { + "date": "2015-06-15T23:30:00", + "username": "jsmith", + "ipAddress": "10.42.10.5", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 52.94867642699313 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 458, + "input": { + "date": "2015-06-15T23:30:00", + "username": "alee", + "ipAddress": "10.42.10.5", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 45.982892142331046 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 459, + "input": { + "date": "2015-06-15T23:30:00", + "username": "mchen", + "ipAddress": "10.42.10.5", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 49.465784284662085 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 460, + "input": { + "date": "2015-06-15T23:30:00", + "username": "kpatel", + "ipAddress": "10.42.10.5", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 69.8802449963173 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 461, + "input": { + "date": "2015-06-15T23:30:00", + "username": "bwong", + "ipAddress": "10.42.0.1", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 35.0 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 462, + "input": { + "date": "2015-06-15T23:30:00", + "username": "tdavis", + "ipAddress": "10.42.0.1", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 52.94867642699313 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 463, + "input": { + "date": "2015-06-15T23:30:00", + "username": "nrhine", + "ipAddress": "10.42.0.1", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 45.982892142331046 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 464, + "input": { + "date": "2015-06-15T23:30:00", + "username": "jsmith", + "ipAddress": "10.42.0.1", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 49.465784284662085 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 465, + "input": { + "date": "2015-06-15T23:30:00", + "username": "alee", + "ipAddress": "10.42.0.1", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 0, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 69.8802449963173 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 466, + "input": { + "date": "2015-06-15T23:30:00", + "username": "mchen", + "ipAddress": "10.24.5.10", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 45.0 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 467, + "input": { + "date": "2015-06-15T23:30:00", + "username": "kpatel", + "ipAddress": "10.24.5.10", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 62.94867642699313 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 468, + "input": { + "date": "2015-06-15T23:30:00", + "username": "bwong", + "ipAddress": "10.24.5.10", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 55.982892142331046 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 469, + "input": { + "date": "2015-06-15T23:30:00", + "username": "tdavis", + "ipAddress": "10.24.5.10", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 59.465784284662085 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 470, + "input": { + "date": "2015-06-15T23:30:00", + "username": "nrhine", + "ipAddress": "10.24.5.10", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 79.8802449963173 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 471, + "input": { + "date": "2015-06-15T23:30:00", + "username": "jsmith", + "ipAddress": "10.26.8.20", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 45.0 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 472, + "input": { + "date": "2015-06-15T23:30:00", + "username": "alee", + "ipAddress": "10.26.8.20", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 62.94867642699313 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 473, + "input": { + "date": "2015-06-15T23:30:00", + "username": "mchen", + "ipAddress": "10.26.8.20", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 55.982892142331046 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 474, + "input": { + "date": "2015-06-15T23:30:00", + "username": "kpatel", + "ipAddress": "10.26.8.20", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 59.465784284662085 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 475, + "input": { + "date": "2015-06-15T23:30:00", + "username": "bwong", + "ipAddress": "10.26.8.20", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 79.8802449963173 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 476, + "input": { + "date": "2015-06-15T23:30:00", + "username": "tdavis", + "ipAddress": "172.16.100.1", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 45.0 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 477, + "input": { + "date": "2015-06-15T23:30:00", + "username": "nrhine", + "ipAddress": "172.16.100.1", + "success": false, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 62.94867642699313 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 478, + "input": { + "date": "2015-06-15T23:30:00", + "username": "jsmith", + "ipAddress": "172.16.100.1", + "success": false, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 55.982892142331046 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 479, + "input": { + "date": "2015-06-15T23:30:00", + "username": "alee", + "ipAddress": "172.16.100.1", + "success": false, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 59.465784284662085 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 480, + "input": { + "date": "2015-06-15T23:30:00", + "username": "mchen", + "ipAddress": "172.16.100.1", + "success": false, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 35, + "ip_location": 10, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 79.8802449963173 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 481, + "input": { + "date": "2015-06-16T04:30:00", + "username": "kpatel", + "ipAddress": "8.8.8.8", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 15.0 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 482, + "input": { + "date": "2015-06-16T04:30:00", + "username": "bwong", + "ipAddress": "8.8.8.8", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 32.94867642699313 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 483, + "input": { + "date": "2015-06-16T04:30:00", + "username": "tdavis", + "ipAddress": "8.8.8.8", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 25.982892142331043 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 484, + "input": { + "date": "2015-06-16T04:30:00", + "username": "nrhine", + "ipAddress": "8.8.8.8", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 29.465784284662085 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 485, + "input": { + "date": "2015-06-16T04:30:00", + "username": "jsmith", + "ipAddress": "8.8.8.8", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 49.8802449963173 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 486, + "input": { + "date": "2015-06-16T04:30:00", + "username": "alee", + "ipAddress": "1.2.3.4", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 15.0 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 487, + "input": { + "date": "2015-06-16T04:30:00", + "username": "mchen", + "ipAddress": "1.2.3.4", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 32.94867642699313 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 488, + "input": { + "date": "2015-06-16T04:30:00", + "username": "kpatel", + "ipAddress": "1.2.3.4", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 25.982892142331043 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 489, + "input": { + "date": "2015-06-16T04:30:00", + "username": "bwong", + "ipAddress": "1.2.3.4", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 29.465784284662085 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 490, + "input": { + "date": "2015-06-16T04:30:00", + "username": "tdavis", + "ipAddress": "1.2.3.4", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 49.8802449963173 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 491, + "input": { + "date": "2015-06-16T04:30:00", + "username": "nrhine", + "ipAddress": "203.0.113.50", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 15.0 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 492, + "input": { + "date": "2015-06-16T04:30:00", + "username": "jsmith", + "ipAddress": "203.0.113.50", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 32.94867642699313 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 493, + "input": { + "date": "2015-06-16T04:30:00", + "username": "alee", + "ipAddress": "203.0.113.50", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 25.982892142331043 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 494, + "input": { + "date": "2015-06-16T04:30:00", + "username": "mchen", + "ipAddress": "203.0.113.50", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 29.465784284662085 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 495, + "input": { + "date": "2015-06-16T04:30:00", + "username": "kpatel", + "ipAddress": "203.0.113.50", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 49.8802449963173 + }, + "meta": { + "ip_class": "external" + } + }, + { + "id": 496, + "input": { + "date": "2015-06-16T04:30:00", + "username": "bwong", + "ipAddress": "10.42.10.5", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 0.0 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 497, + "input": { + "date": "2015-06-16T04:30:00", + "username": "tdavis", + "ipAddress": "10.42.10.5", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 17.94867642699313 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 498, + "input": { + "date": "2015-06-16T04:30:00", + "username": "nrhine", + "ipAddress": "10.42.10.5", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 10.982892142331043 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 499, + "input": { + "date": "2015-06-16T04:30:00", + "username": "jsmith", + "ipAddress": "10.42.10.5", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 14.465784284662089 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 500, + "input": { + "date": "2015-06-16T04:30:00", + "username": "alee", + "ipAddress": "10.42.10.5", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 34.88024499631731 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 501, + "input": { + "date": "2015-06-16T04:30:00", + "username": "mchen", + "ipAddress": "10.42.0.1", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 0.0 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 502, + "input": { + "date": "2015-06-16T04:30:00", + "username": "kpatel", + "ipAddress": "10.42.0.1", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 17.94867642699313 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 503, + "input": { + "date": "2015-06-16T04:30:00", + "username": "bwong", + "ipAddress": "10.42.0.1", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 10.982892142331043 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 504, + "input": { + "date": "2015-06-16T04:30:00", + "username": "tdavis", + "ipAddress": "10.42.0.1", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 14.465784284662089 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 505, + "input": { + "date": "2015-06-16T04:30:00", + "username": "nrhine", + "ipAddress": "10.42.0.1", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 0, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 34.88024499631731 + }, + "meta": { + "ip_class": "vpn" + } + }, + { + "id": 506, + "input": { + "date": "2015-06-16T04:30:00", + "username": "jsmith", + "ipAddress": "10.24.5.10", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 10.0 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 507, + "input": { + "date": "2015-06-16T04:30:00", + "username": "alee", + "ipAddress": "10.24.5.10", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 27.94867642699313 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 508, + "input": { + "date": "2015-06-16T04:30:00", + "username": "mchen", + "ipAddress": "10.24.5.10", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 20.982892142331043 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 509, + "input": { + "date": "2015-06-16T04:30:00", + "username": "kpatel", + "ipAddress": "10.24.5.10", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 24.465784284662085 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 510, + "input": { + "date": "2015-06-16T04:30:00", + "username": "bwong", + "ipAddress": "10.24.5.10", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 44.8802449963173 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 511, + "input": { + "date": "2015-06-16T04:30:00", + "username": "tdavis", + "ipAddress": "10.26.8.20", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 10.0 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 512, + "input": { + "date": "2015-06-16T04:30:00", + "username": "nrhine", + "ipAddress": "10.26.8.20", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 27.94867642699313 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 513, + "input": { + "date": "2015-06-16T04:30:00", + "username": "jsmith", + "ipAddress": "10.26.8.20", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 20.982892142331043 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 514, + "input": { + "date": "2015-06-16T04:30:00", + "username": "alee", + "ipAddress": "10.26.8.20", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 24.465784284662085 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 515, + "input": { + "date": "2015-06-16T04:30:00", + "username": "mchen", + "ipAddress": "10.26.8.20", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 44.8802449963173 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 516, + "input": { + "date": "2015-06-16T04:30:00", + "username": "kpatel", + "ipAddress": "172.16.100.1", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 10.0 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 517, + "input": { + "date": "2015-06-16T04:30:00", + "username": "bwong", + "ipAddress": "172.16.100.1", + "success": true, + "server": "ae1-app80-prd" + }, + "frequencies": { + "hour": 0.5, + "day": 0.25, + "server": 0.1, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 1.0, + "days": 2.0, + "server": 4.9828921423310435, + "ip": 9.965784284662087, + "total": 27.94867642699313 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 518, + "input": { + "date": "2015-06-16T04:30:00", + "username": "tdavis", + "ipAddress": "172.16.100.1", + "success": true, + "server": "web-prod-01" + }, + "frequencies": { + "hour": 0.25, + "day": 0.5, + "server": 0.25, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 2.0, + "days": 1.0, + "server": 3.0, + "ip": 4.9828921423310435, + "total": 20.982892142331043 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 519, + "input": { + "date": "2015-06-16T04:30:00", + "username": "nrhine", + "ipAddress": "172.16.100.1", + "success": true, + "server": "db-staging-02" + }, + "frequencies": { + "hour": 0.1, + "day": 0.01, + "server": 0.5, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 3.3219280948873626, + "days": 6.643856189774725, + "server": 1.5, + "ip": 3.0, + "total": 24.465784284662085 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 520, + "input": { + "date": "2015-06-16T04:30:00", + "username": "jsmith", + "ipAddress": "172.16.100.1", + "success": true, + "server": "mail-relay-03" + }, + "frequencies": { + "hour": 0.01, + "day": 0.1, + "server": 0.01, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 10, + "hours": 6.643856189774725, + "days": 3.3219280948873626, + "server": 9.965784284662087, + "ip": 14.948676426993131, + "total": 44.8802449963173 + }, + "meta": { + "ip_class": "internal" + } + }, + { + "id": 521, + "input": { + "date": "2015-01-01T09:00:00", + "username": "edge_user", + "ipAddress": "8.8.8.8", + "success": true, + "server": "edge-server" + }, + "frequencies": { + "hour": 1.0, + "day": 1.0, + "server": 1.0, + "ip": 1.0 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": -0.0, + "days": -0.0, + "server": -0.0, + "ip": -0.0, + "total": 15.0 + }, + "meta": { + "edge_case": "uniform_frequency", + "frequency": 1.0 + } + }, + { + "id": 522, + "input": { + "date": "2015-01-01T09:00:00", + "username": "edge_user", + "ipAddress": "8.8.8.8", + "success": true, + "server": "edge-server" + }, + "frequencies": { + "hour": 0.5, + "day": 0.5, + "server": 0.5, + "ip": 0.5 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 1.0, + "days": 1.0, + "server": 1.5, + "ip": 1.5, + "total": 20.0 + }, + "meta": { + "edge_case": "uniform_frequency", + "frequency": 0.5 + } + }, + { + "id": 523, + "input": { + "date": "2015-01-01T09:00:00", + "username": "edge_user", + "ipAddress": "8.8.8.8", + "success": true, + "server": "edge-server" + }, + "frequencies": { + "hour": 0.25, + "day": 0.25, + "server": 0.25, + "ip": 0.25 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 2.0, + "days": 2.0, + "server": 3.0, + "ip": 3.0, + "total": 25.0 + }, + "meta": { + "edge_case": "uniform_frequency", + "frequency": 0.25 + } + }, + { + "id": 524, + "input": { + "date": "2015-01-01T09:00:00", + "username": "edge_user", + "ipAddress": "8.8.8.8", + "success": true, + "server": "edge-server" + }, + "frequencies": { + "hour": 0.1, + "day": 0.1, + "server": 0.1, + "ip": 0.1 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 3.3219280948873626, + "days": 3.3219280948873626, + "server": 4.9828921423310435, + "ip": 4.9828921423310435, + "total": 31.60964047443681 + }, + "meta": { + "edge_case": "uniform_frequency", + "frequency": 0.1 + } + }, + { + "id": 525, + "input": { + "date": "2015-01-01T09:00:00", + "username": "edge_user", + "ipAddress": "8.8.8.8", + "success": true, + "server": "edge-server" + }, + "frequencies": { + "hour": 0.01, + "day": 0.01, + "server": 0.01, + "ip": 0.01 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 6.643856189774725, + "days": 6.643856189774725, + "server": 9.965784284662087, + "ip": 9.965784284662087, + "total": 48.21928094887362 + }, + "meta": { + "edge_case": "uniform_frequency", + "frequency": 0.01 + } + }, + { + "id": 526, + "input": { + "date": "2015-01-01T09:00:00", + "username": "edge_user", + "ipAddress": "8.8.8.8", + "success": true, + "server": "edge-server" + }, + "frequencies": { + "hour": 0.001, + "day": 0.001, + "server": 0.001, + "ip": 0.001 + }, + "expected": { + "success": 0, + "ip_location": 15, + "hours": 9.965784284662087, + "days": 9.965784284662087, + "server": 14.948676426993131, + "ip": 14.948676426993131, + "total": 64.82892142331043 + }, + "meta": { + "edge_case": "uniform_frequency", + "frequency": 0.001 + } + }, + { + "id": 527, + "input": { + "date": "2015-01-01T09:00:00", + "username": "edge_user", + "ipAddress": "8.8.8.8", + "success": true, + "server": "edge-server" + }, + "frequencies": { + "hour": 0.0001, + "day": 0.0001, + "server": 0.0001, + "ip": 0.0001 + }, + "expected": { + "success": 0.0, + "ip_location": 15.0, + "hours": 10.0, + "days": 10.0, + "server": 15.0, + "ip": 15.0, + "total": 65.0 + }, + "meta": { + "edge_case": "uniform_frequency", + "frequency": 0.0001 + } + } + ] +} diff --git a/tests/fixtures/syslog_corpus.json b/tests/fixtures/syslog_corpus.json new file mode 100644 index 0000000..8c1b222 --- /dev/null +++ b/tests/fixtures/syslog_corpus.json @@ -0,0 +1,904 @@ +{ + "message_count": 66, + "patterns": { + "test_enabled_failure": "pam_unix\\(sshd:auth\\):\\s+authentication\\s+failure\\;\\s+login=\\s+uid=0\\s+euid=0\\s+tty=ssh+\\s+ruser=+\\s+rhost=(\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3})\\s+user=([0-9a-zA-Z_-]+)\\s+DATE_TIME\\s+(\\d{1,4}-\\d{1,2}-\\d{1,2}\\s+\\d{2}:\\d{2}:\\d{2})\\s+HOST\\s+([\\w\\+%\\-& ]+)", + "test_enabled_success": "Accepted\\s+publickey\\s+for\\s+([0-9a-zA-Z_-]+)\\s+from\\s+(\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3})\\s+port\\s+(\\d{1,4})+\\s+ssh2+\\s+DATE_TIME\\s+(\\d{1,4}-\\d{1,2}-\\d{1,2}\\s+\\d{2}:\\d{2}:\\d{2})\\s+HOST\\s+([\\w\\+%\\-& ]+)" + }, + "version": 1, + "messages": [ + { + "category": "linux_ssh_success", + "host": "192.168.56.1", + "raw": "<14>sshd[3000]: Accepted publickey for kantselovich from 10.42.10.2 port 2000 ssh2", + "test_enabled": false, + "expected": { + "username": "kantselovich", + "date": null, + "ipAddress": "10.42.10.2", + "success": true, + "server": "192.168.56.1" + }, + "skip_date_assertion": true, + "id": 1 + }, + { + "category": "linux_ssh_success", + "host": "192.168.56.1", + "raw": "<14>sshd[3001]: Accepted publickey for nrhine from 10.42.28.46 port 2001 ssh2", + "test_enabled": false, + "expected": { + "username": "nrhine", + "date": null, + "ipAddress": "10.42.28.46", + "success": true, + "server": "192.168.56.1" + }, + "skip_date_assertion": true, + "id": 2 + }, + { + "category": "linux_ssh_success", + "host": "192.168.56.1", + "raw": "<14>sshd[3002]: Accepted publickey for jsmith from 10.42.10.22 port 2002 ssh2", + "test_enabled": false, + "expected": { + "username": "jsmith", + "date": null, + "ipAddress": "10.42.10.22", + "success": true, + "server": "192.168.56.1" + }, + "skip_date_assertion": true, + "id": 3 + }, + { + "category": "linux_ssh_success", + "host": "192.168.56.1", + "raw": "<14>sshd[3003]: Accepted publickey for dchiu from 192.168.1.50 port 2003 ssh2", + "test_enabled": false, + "expected": { + "username": "dchiu", + "date": null, + "ipAddress": "192.168.1.50", + "success": true, + "server": "192.168.56.1" + }, + "skip_date_assertion": true, + "id": 4 + }, + { + "category": "linux_ssh_success", + "host": "192.168.56.1", + "raw": "<14>sshd[3004]: Accepted publickey for msacks from 172.16.0.5 port 2004 ssh2", + "test_enabled": false, + "expected": { + "username": "msacks", + "date": null, + "ipAddress": "172.16.0.5", + "success": true, + "server": "192.168.56.1" + }, + "skip_date_assertion": true, + "id": 5 + }, + { + "category": "linux_ssh_success", + "host": "192.168.56.1", + "raw": "<14>sshd[3005]: Accepted publickey for alee from 10.42.10.2 port 2005 ssh2", + "test_enabled": false, + "expected": { + "username": "alee", + "date": null, + "ipAddress": "10.42.10.2", + "success": true, + "server": "192.168.56.1" + }, + "skip_date_assertion": true, + "id": 6 + }, + { + "category": "linux_ssh_success", + "host": "192.168.56.1", + "raw": "<14>sshd[3006]: Accepted publickey for mchen from 10.42.28.46 port 2006 ssh2", + "test_enabled": false, + "expected": { + "username": "mchen", + "date": null, + "ipAddress": "10.42.28.46", + "success": true, + "server": "192.168.56.1" + }, + "skip_date_assertion": true, + "id": 7 + }, + { + "category": "linux_ssh_success", + "host": "192.168.56.1", + "raw": "<14>sshd[3007]: Accepted publickey for bwong from 10.42.10.22 port 2007 ssh2", + "test_enabled": false, + "expected": { + "username": "bwong", + "date": null, + "ipAddress": "10.42.10.22", + "success": true, + "server": "192.168.56.1" + }, + "skip_date_assertion": true, + "id": 8 + }, + { + "category": "linux_ssh_success", + "host": "192.168.56.1", + "raw": "<14>sshd[3008]: Accepted publickey for tdavis from 192.168.1.50 port 2008 ssh2", + "test_enabled": false, + "expected": { + "username": "tdavis", + "date": null, + "ipAddress": "192.168.1.50", + "success": true, + "server": "192.168.56.1" + }, + "skip_date_assertion": true, + "id": 9 + }, + { + "category": "linux_ssh_success", + "host": "192.168.56.1", + "raw": "<14>sshd[3009]: Accepted publickey for kpatel from 172.16.0.5 port 2009 ssh2", + "test_enabled": false, + "expected": { + "username": "kpatel", + "date": null, + "ipAddress": "172.16.0.5", + "success": true, + "server": "192.168.56.1" + }, + "skip_date_assertion": true, + "id": 10 + }, + { + "category": "linux_ssh_success", + "host": "192.168.56.1", + "raw": "<14>sshd[3010]: Accepted publickey for devops from 10.42.10.2 port 2010 ssh2", + "test_enabled": false, + "expected": { + "username": "devops", + "date": null, + "ipAddress": "10.42.10.2", + "success": true, + "server": "192.168.56.1" + }, + "skip_date_assertion": true, + "id": 11 + }, + { + "category": "linux_ssh_success", + "host": "192.168.56.1", + "raw": "<14>sshd[4000]: Accepted publickey for kantselovich from 10.42.10.2 port 7786 ssh2 DATE_TIME 2013-09-10 11:16:48 HOST ae1-app80-prd", + "test_enabled": true, + "expected": { + "username": "kantselovich", + "date": "2013-09-10 11:16:48", + "ipAddress": "10.42.10.2", + "success": true, + "server": "ae1-app80-prd" + }, + "id": 12 + }, + { + "category": "linux_ssh_success", + "host": "192.168.56.1", + "raw": "<14>sshd[4001]: Accepted publickey for nrhine from 10.42.28.46 port 7787 ssh2 DATE_TIME 2013-09-11 11:17:48 HOST ae1-app80-prd", + "test_enabled": true, + "expected": { + "username": "nrhine", + "date": "2013-09-11 11:17:48", + "ipAddress": "10.42.28.46", + "success": true, + "server": "ae1-app80-prd" + }, + "id": 13 + }, + { + "category": "linux_ssh_success", + "host": "192.168.56.1", + "raw": "<14>sshd[4002]: Accepted publickey for jsmith from 10.42.10.22 port 7788 ssh2 DATE_TIME 2013-09-12 11:18:48 HOST ae1-app80-prd", + "test_enabled": true, + "expected": { + "username": "jsmith", + "date": "2013-09-12 11:18:48", + "ipAddress": "10.42.10.22", + "success": true, + "server": "ae1-app80-prd" + }, + "id": 14 + }, + { + "category": "linux_ssh_success", + "host": "192.168.56.1", + "raw": "<14>sshd[4003]: Accepted publickey for dchiu from 192.168.1.50 port 7789 ssh2 DATE_TIME 2013-09-13 11:19:48 HOST ae1-app80-prd", + "test_enabled": true, + "expected": { + "username": "dchiu", + "date": "2013-09-13 11:19:48", + "ipAddress": "192.168.1.50", + "success": true, + "server": "ae1-app80-prd" + }, + "id": 15 + }, + { + "category": "linux_ssh_success", + "host": "192.168.56.1", + "raw": "<14>sshd[4004]: Accepted publickey for msacks from 172.16.0.5 port 7790 ssh2 DATE_TIME 2013-09-14 11:20:48 HOST ae1-app80-prd", + "test_enabled": true, + "expected": { + "username": "msacks", + "date": "2013-09-14 11:20:48", + "ipAddress": "172.16.0.5", + "success": true, + "server": "ae1-app80-prd" + }, + "id": 16 + }, + { + "category": "linux_ssh_success", + "host": "192.168.56.1", + "raw": "<14>sshd[4005]: Accepted publickey for alee from 10.42.10.2 port 7791 ssh2 DATE_TIME 2013-09-15 11:21:48 HOST ae1-app80-prd", + "test_enabled": true, + "expected": { + "username": "alee", + "date": "2013-09-15 11:21:48", + "ipAddress": "10.42.10.2", + "success": true, + "server": "ae1-app80-prd" + }, + "id": 17 + }, + { + "category": "linux_ssh_failure", + "host": "192.168.56.1", + "raw": "<14>sshd[5000]: pam_unix(sshd:auth): authentication failure; login= uid=0 euid=0 tty=ssh ruser= rhost=10.42.10.22 user=kantselovich", + "test_enabled": false, + "expected": { + "username": "kantselovich", + "date": null, + "ipAddress": "10.42.10.22", + "success": false, + "server": "192.168.56.1" + }, + "skip_date_assertion": true, + "id": 18 + }, + { + "category": "linux_ssh_failure", + "host": "192.168.56.1", + "raw": "<14>sshd[5001]: pam_unix(sshd:auth): authentication failure; login= uid=0 euid=0 tty=ssh ruser= rhost=192.168.1.50 user=nrhine", + "test_enabled": false, + "expected": { + "username": "nrhine", + "date": null, + "ipAddress": "192.168.1.50", + "success": false, + "server": "192.168.56.1" + }, + "skip_date_assertion": true, + "id": 19 + }, + { + "category": "linux_ssh_failure", + "host": "192.168.56.1", + "raw": "<14>sshd[5002]: pam_unix(sshd:auth): authentication failure; login= uid=0 euid=0 tty=ssh ruser= rhost=172.16.0.5 user=jsmith", + "test_enabled": false, + "expected": { + "username": "jsmith", + "date": null, + "ipAddress": "172.16.0.5", + "success": false, + "server": "192.168.56.1" + }, + "skip_date_assertion": true, + "id": 20 + }, + { + "category": "linux_ssh_failure", + "host": "192.168.56.1", + "raw": "<14>sshd[5003]: pam_unix(sshd:auth): authentication failure; login= uid=0 euid=0 tty=ssh ruser= rhost=10.42.10.2 user=dchiu", + "test_enabled": false, + "expected": { + "username": "dchiu", + "date": null, + "ipAddress": "10.42.10.2", + "success": false, + "server": "192.168.56.1" + }, + "skip_date_assertion": true, + "id": 21 + }, + { + "category": "linux_ssh_failure", + "host": "192.168.56.1", + "raw": "<14>sshd[5004]: pam_unix(sshd:auth): authentication failure; login= uid=0 euid=0 tty=ssh ruser= rhost=10.42.28.46 user=msacks", + "test_enabled": false, + "expected": { + "username": "msacks", + "date": null, + "ipAddress": "10.42.28.46", + "success": false, + "server": "192.168.56.1" + }, + "skip_date_assertion": true, + "id": 22 + }, + { + "category": "linux_ssh_failure", + "host": "192.168.56.1", + "raw": "<14>sshd[5005]: pam_unix(sshd:auth): authentication failure; login= uid=0 euid=0 tty=ssh ruser= rhost=10.42.10.22 user=alee", + "test_enabled": false, + "expected": { + "username": "alee", + "date": null, + "ipAddress": "10.42.10.22", + "success": false, + "server": "192.168.56.1" + }, + "skip_date_assertion": true, + "id": 23 + }, + { + "category": "linux_ssh_failure", + "host": "192.168.56.1", + "raw": "<14>sshd[5006]: pam_unix(sshd:auth): authentication failure; login= uid=0 euid=0 tty=ssh ruser= rhost=192.168.1.50 user=mchen", + "test_enabled": false, + "expected": { + "username": "mchen", + "date": null, + "ipAddress": "192.168.1.50", + "success": false, + "server": "192.168.56.1" + }, + "skip_date_assertion": true, + "id": 24 + }, + { + "category": "linux_ssh_failure", + "host": "192.168.56.1", + "raw": "<14>sshd[5007]: pam_unix(sshd:auth): authentication failure; login= uid=0 euid=0 tty=ssh ruser= rhost=172.16.0.5 user=bwong", + "test_enabled": false, + "expected": { + "username": "bwong", + "date": null, + "ipAddress": "172.16.0.5", + "success": false, + "server": "192.168.56.1" + }, + "skip_date_assertion": true, + "id": 25 + }, + { + "category": "linux_ssh_failure", + "host": "192.168.56.1", + "raw": "<14>sshd[5008]: pam_unix(sshd:auth): authentication failure; login= uid=0 euid=0 tty=ssh ruser= rhost=10.42.10.2 user=tdavis", + "test_enabled": false, + "expected": { + "username": "tdavis", + "date": null, + "ipAddress": "10.42.10.2", + "success": false, + "server": "192.168.56.1" + }, + "skip_date_assertion": true, + "id": 26 + }, + { + "category": "linux_ssh_failure", + "host": "192.168.56.1", + "raw": "<14>sshd[5009]: pam_unix(sshd:auth): authentication failure; login= uid=0 euid=0 tty=ssh ruser= rhost=10.42.28.46 user=kpatel", + "test_enabled": false, + "expected": { + "username": "kpatel", + "date": null, + "ipAddress": "10.42.28.46", + "success": false, + "server": "192.168.56.1" + }, + "skip_date_assertion": true, + "id": 27 + }, + { + "category": "linux_ssh_failure", + "host": "192.168.56.1", + "raw": "<14>sshd[5010]: pam_unix(sshd:auth): authentication failure; login= uid=0 euid=0 tty=ssh ruser= rhost=10.42.10.22 user=devops", + "test_enabled": false, + "expected": { + "username": "devops", + "date": null, + "ipAddress": "10.42.10.22", + "success": false, + "server": "192.168.56.1" + }, + "skip_date_assertion": true, + "id": 28 + }, + { + "category": "linux_ssh_failure", + "host": "192.168.56.1", + "raw": "<14>sshd[6000]: pam_unix(sshd:auth): authentication failure; login= uid=0 euid=0 tty=ssh ruser= rhost=10.42.28.46 user=kantselovich DATE_TIME 2013-10-05 14:30:30 HOST db-staging-02", + "test_enabled": true, + "expected": { + "username": "kantselovich", + "date": "2013-10-05 14:30:30", + "ipAddress": "10.42.28.46", + "success": false, + "server": "db-staging-02" + }, + "id": 29 + }, + { + "category": "linux_ssh_failure", + "host": "192.168.56.1", + "raw": "<14>sshd[6001]: pam_unix(sshd:auth): authentication failure; login= uid=0 euid=0 tty=ssh ruser= rhost=10.42.10.22 user=nrhine DATE_TIME 2013-10-06 14:31:30 HOST db-staging-02", + "test_enabled": true, + "expected": { + "username": "nrhine", + "date": "2013-10-06 14:31:30", + "ipAddress": "10.42.10.22", + "success": false, + "server": "db-staging-02" + }, + "id": 30 + }, + { + "category": "linux_ssh_failure", + "host": "192.168.56.1", + "raw": "<14>sshd[6002]: pam_unix(sshd:auth): authentication failure; login= uid=0 euid=0 tty=ssh ruser= rhost=192.168.1.50 user=jsmith DATE_TIME 2013-10-07 14:32:30 HOST db-staging-02", + "test_enabled": true, + "expected": { + "username": "jsmith", + "date": "2013-10-07 14:32:30", + "ipAddress": "192.168.1.50", + "success": false, + "server": "db-staging-02" + }, + "id": 31 + }, + { + "category": "linux_ssh_failure", + "host": "192.168.56.1", + "raw": "<14>sshd[6003]: pam_unix(sshd:auth): authentication failure; login= uid=0 euid=0 tty=ssh ruser= rhost=172.16.0.5 user=dchiu DATE_TIME 2013-10-08 14:33:30 HOST db-staging-02", + "test_enabled": true, + "expected": { + "username": "dchiu", + "date": "2013-10-08 14:33:30", + "ipAddress": "172.16.0.5", + "success": false, + "server": "db-staging-02" + }, + "id": 32 + }, + { + "category": "linux_ssh_failure", + "host": "192.168.56.1", + "raw": "<14>sshd[6004]: pam_unix(sshd:auth): authentication failure; login= uid=0 euid=0 tty=ssh ruser= rhost=10.42.10.2 user=msacks DATE_TIME 2013-10-09 14:34:30 HOST db-staging-02", + "test_enabled": true, + "expected": { + "username": "msacks", + "date": "2013-10-09 14:34:30", + "ipAddress": "10.42.10.2", + "success": false, + "server": "db-staging-02" + }, + "id": 33 + }, + { + "category": "linux_ssh_failure", + "host": "192.168.56.1", + "raw": "<14>sshd[6005]: pam_unix(sshd:auth): authentication failure; login= uid=0 euid=0 tty=ssh ruser= rhost=10.42.28.46 user=alee DATE_TIME 2013-10-10 14:35:30 HOST db-staging-02", + "test_enabled": true, + "expected": { + "username": "alee", + "date": "2013-10-10 14:35:30", + "ipAddress": "10.42.28.46", + "success": false, + "server": "db-staging-02" + }, + "id": 34 + }, + { + "category": "windows_security_audit", + "host": "192.168.56.1", + "raw": "<14>Oct 10 14:26:09 WIN-DEV-00 Security-Auditing: 4624: AUDIT_SUCCESS An account was successfully logged on. Subject: Security ID: S-1-5-18 Account Name: WIN-DEV-00$ Account Domain: WORKGROUP Logon ID: 0x3e7 Logon Type: 2 New Logon: Security ID: S-1-5-21-1223658549-3667468651-3388596622-1001 Account Name: developer Account Domain: win-dev-00 Logon ID: 0x8b32b5 Network Information: Workstation Name: WIN-DEV-00 Source Network Address: 127.0.0.1 Source Port: 0 Detailed Authentication Information: Logon Process: User32", + "test_enabled": false, + "expected": { + "username": "developer", + "date": null, + "ipAddress": "127.0.0.1", + "success": true, + "server": "WIN-DEV-00" + }, + "skip_date_assertion": true, + "id": 35 + }, + { + "category": "windows_security_audit", + "host": "192.168.56.1", + "raw": "<14>Oct 15 09:15:00 WIN-DEV-01 Security-Auditing: 4624: AUDIT_SUCCESS An account was successfully logged on. Subject: Security ID: S-1-5-18 Account Name: WIN-DEV-01$ Account Domain: WORKGROUP Logon ID: 0x3e7 Logon Type: 2 New Logon: Security ID: S-1-5-21-1223658549-3667468651-3388596622-1001 Account Name: admin Account Domain: win-dev-01 Logon ID: 0x8b32b5 Network Information: Workstation Name: WIN-DEV-01 Source Network Address: 10.24.5.10 Source Port: 0 Detailed Authentication Information: Logon Process: User32", + "test_enabled": false, + "expected": { + "username": "admin", + "date": null, + "ipAddress": "10.24.5.10", + "success": true, + "server": "WIN-DEV-01" + }, + "skip_date_assertion": true, + "id": 36 + }, + { + "category": "windows_security_audit", + "host": "192.168.56.1", + "raw": "<14>Oct 20 22:45:33 WIN-DEV-02 Security-Auditing: 4624: AUDIT_SUCCESS An account was successfully logged on. Subject: Security ID: S-1-5-18 Account Name: WIN-DEV-02$ Account Domain: WORKGROUP Logon ID: 0x3e7 Logon Type: 2 New Logon: Security ID: S-1-5-21-1223658549-3667468651-3388596622-1001 Account Name: svc_backup Account Domain: win-dev-02 Logon ID: 0x8b32b5 Network Information: Workstation Name: WIN-DEV-02 Source Network Address: 10.26.8.20 Source Port: 0 Detailed Authentication Information: Logon Process: User32", + "test_enabled": false, + "expected": { + "username": "svc_backup", + "date": null, + "ipAddress": "10.26.8.20", + "success": true, + "server": "WIN-DEV-02" + }, + "skip_date_assertion": true, + "id": 37 + }, + { + "category": "windows_security_audit", + "host": "192.168.56.1", + "raw": "<14>Oct 01 03:00:01 WIN-DEV-03 Security-Auditing: 4624: AUDIT_SUCCESS An account was successfully logged on. Subject: Security ID: S-1-5-18 Account Name: WIN-DEV-03$ Account Domain: WORKGROUP Logon ID: 0x3e7 Logon Type: 2 New Logon: Security ID: S-1-5-21-1223658549-3667468651-3388596622-1001 Account Name: jsmith Account Domain: win-dev-03 Logon ID: 0x8b32b5 Network Information: Workstation Name: WIN-DEV-03 Source Network Address: 203.0.113.50 Source Port: 0 Detailed Authentication Information: Logon Process: User32", + "test_enabled": false, + "expected": { + "username": "jsmith", + "date": null, + "ipAddress": "203.0.113.50", + "success": true, + "server": "WIN-DEV-03" + }, + "skip_date_assertion": true, + "id": 38 + }, + { + "category": "windows_security_audit", + "host": "192.168.56.1", + "raw": "<14>Oct 28 18:30:45 WIN-DEV-04 Security-Auditing: 4624: AUDIT_SUCCESS An account was successfully logged on. Subject: Security ID: S-1-5-18 Account Name: WIN-DEV-04$ Account Domain: WORKGROUP Logon ID: 0x3e7 Logon Type: 2 New Logon: Security ID: S-1-5-21-1223658549-3667468651-3388596622-1001 Account Name: mchen Account Domain: win-dev-04 Logon ID: 0x8b32b5 Network Information: Workstation Name: WIN-DEV-04 Source Network Address: 172.16.100.1 Source Port: 0 Detailed Authentication Information: Logon Process: User32", + "test_enabled": false, + "expected": { + "username": "mchen", + "date": null, + "ipAddress": "172.16.100.1", + "success": true, + "server": "WIN-DEV-04" + }, + "skip_date_assertion": true, + "id": 39 + }, + { + "category": "windows_security_audit", + "host": "192.168.56.1", + "raw": "<14>Oct 05 12:00:00 WIN-DEV-05 Security-Auditing: 4624: AUDIT_SUCCESS An account was successfully logged on. Subject: Security ID: S-1-5-18 Account Name: WIN-DEV-05$ Account Domain: WORKGROUP Logon ID: 0x3e7 Logon Type: 2 New Logon: Security ID: S-1-5-21-1223658549-3667468651-3388596622-1001 Account Name: alee Account Domain: win-dev-05 Logon ID: 0x8b32b5 Network Information: Workstation Name: WIN-DEV-05 Source Network Address: 10.42.1.100 Source Port: 0 Detailed Authentication Information: Logon Process: User32", + "test_enabled": false, + "expected": { + "username": "alee", + "date": null, + "ipAddress": "10.42.1.100", + "success": true, + "server": "WIN-DEV-05" + }, + "skip_date_assertion": true, + "id": 40 + }, + { + "category": "malformed", + "host": "192.168.56.1", + "raw": "", + "test_enabled": false, + "expected": null, + "id": 41 + }, + { + "category": "malformed", + "host": "192.168.56.1", + "raw": "short", + "test_enabled": false, + "expected": null, + "id": 42 + }, + { + "category": "malformed", + "host": "192.168.56.1", + "raw": "<14>sshd: garbage without proper fields", + "test_enabled": false, + "expected": null, + "id": 43 + }, + { + "category": "malformed", + "host": "192.168.56.1", + "raw": "<14>sshd[1]: Accepted publickey for bad-ip from not-an-ip port x ssh2", + "test_enabled": false, + "expected": null, + "id": 44 + }, + { + "category": "malformed", + "host": "192.168.56.1", + "raw": "<14>sshd[1]: pam_unix(sshd:auth): authentication failure incomplete", + "test_enabled": false, + "expected": null, + "id": 45 + }, + { + "category": "malformed", + "host": "192.168.56.1", + "raw": "Oct 10 incomplete windows line without audit markers", + "test_enabled": false, + "expected": null, + "id": 46 + }, + { + "category": "malformed", + "host": "192.168.56.1", + "raw": "<14>Oct 10 14:26:09 HOST Security-Auditing: 4624 missing account fields", + "test_enabled": false, + "expected": null, + "id": 47 + }, + { + "category": "malformed", + "host": "192.168.56.1", + "raw": "<14>sshd[1]: Accepted publickey for", + "test_enabled": false, + "expected": null, + "id": 48 + }, + { + "category": "malformed", + "host": "192.168.56.1", + "raw": "embedded-null \u0000 bytes in syslog payload", + "test_enabled": false, + "expected": null, + "id": 49 + }, + { + "category": "malformed", + "host": "192.168.56.1", + "raw": "<14>sshd[1]: random noise Accepted publickey", + "test_enabled": false, + "expected": null, + "id": 50 + }, + { + "category": "malformed", + "host": "192.168.56.1", + "raw": "<14>sshd[1]: Accepted password for user from 1.2.3.4 port 22", + "test_enabled": false, + "expected": null, + "id": 51 + }, + { + "category": "oversized", + "host": "192.168.56.1", + "raw": "<14>sshd[9999]: Accepted publickey for biguser from 10.42.10.99 port 9999 ssh2 XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "test_enabled": false, + "expected": { + "username": "biguser", + "date": null, + "ipAddress": "10.42.10.99", + "success": true, + "server": "192.168.56.1" + }, + "skip_date_assertion": true, + "id": 52 + }, + { + "category": "oversized", + "host": "192.168.56.1", + "raw": "<14>sshd[9999]: Accepted publickey for biguser from 10.42.10.99 port 9999 ssh2 XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "test_enabled": false, + "expected": { + "username": "biguser", + "date": null, + "ipAddress": "10.42.10.99", + "success": true, + "server": "192.168.56.1" + }, + "skip_date_assertion": true, + "id": 53 + }, + { + "category": "oversized", + "host": "192.168.56.1", + "raw": "<14>sshd[9999]: Accepted publickey for biguser from 10.42.10.99 port 9999 ssh2 XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "test_enabled": false, + "expected": { + "username": "biguser", + "date": null, + "ipAddress": "10.42.10.99", + "success": true, + "server": "192.168.56.1" + }, + "skip_date_assertion": true, + "id": 54 + }, + { + "category": "oversized", + "host": "192.168.56.1", + "raw": "<14>sshd[9999]: Accepted publickey for biguser from 10.42.10.99 port 9999 ssh2 XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "test_enabled": false, + "expected": { + "username": "biguser", + "date": null, + "ipAddress": "10.42.10.99", + "success": true, + "server": "192.168.56.1" + }, + "skip_date_assertion": true, + "id": 55 + }, + { + "category": "oversized", + "host": "192.168.56.1", + "raw": "<14>sshd[9999]: Accepted publickey for biguser from 10.42.10.99 port 9999 ssh2 XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "test_enabled": false, + "expected": { + "username": "biguser", + "date": null, + "ipAddress": "10.42.10.99", + "success": true, + "server": "192.168.56.1" + }, + "skip_date_assertion": true, + "id": 56 + }, + { + "category": "injection", + "host": "192.168.56.1", + "raw": "<14>sshd[1]: Accepted publickey for admin-inject from 10.0.0.1 port 22 ssh2", + "test_enabled": false, + "expected": { + "username": "admin-inject", + "date": null, + "ipAddress": "10.0.0.1", + "success": true, + "server": "192.168.56.1" + }, + "skip_date_assertion": true, + "id": 57 + }, + { + "category": "injection", + "host": "192.168.56.1", + "raw": "<14>sshd[1]: pam_unix(sshd:auth): authentication failure; login= uid=0 euid=0 tty=ssh ruser= rhost=10.0.0.2 user=sqlinject", + "test_enabled": false, + "expected": { + "username": "sqlinject", + "date": null, + "ipAddress": "10.0.0.2", + "success": false, + "server": "192.168.56.1" + }, + "skip_date_assertion": true, + "id": 58 + }, + { + "category": "injection", + "host": "192.168.56.1", + "raw": "<14>sshd[1]: Accepted publickey for subshell from 10.0.0.3 port 22 ssh2", + "test_enabled": false, + "expected": { + "username": "subshell", + "date": null, + "ipAddress": "10.0.0.3", + "success": true, + "server": "192.168.56.1" + }, + "skip_date_assertion": true, + "id": 59 + }, + { + "category": "injection", + "host": "192.168.56.1", + "raw": "<14>Oct 10 14:26:09 HOST Security-Auditing: 4624 Account Name: adminx00 Source Network Address: 127.0.0.1 extra", + "test_enabled": false, + "expected": { + "username": "adminx00", + "date": null, + "ipAddress": "127.0.0.1", + "success": true, + "server": "HOST" + }, + "skip_date_assertion": true, + "id": 60 + }, + { + "category": "injection", + "host": "192.168.56.1", + "raw": "<14>sshd[1]: Accepted publickey for path-traversal from 10.0.0.4 port 22 ssh2", + "test_enabled": false, + "expected": { + "username": "path-traversal", + "date": null, + "ipAddress": "10.0.0.4", + "success": true, + "server": "192.168.56.1" + }, + "skip_date_assertion": true, + "id": 61 + }, + { + "category": "edge_case", + "host": "192.168.56.1", + "raw": "<14>sshd[1]: Accepted publickey for a from 255.255.255.255 port 65535 ssh2", + "test_enabled": false, + "expected": { + "username": "a", + "date": null, + "ipAddress": "255.255.255.255", + "success": true, + "server": "192.168.56.1" + }, + "skip_date_assertion": true, + "id": 62 + }, + { + "category": "edge_case", + "host": "192.168.56.1", + "raw": "<14>sshd[1]: Accepted publickey for user_with-dash from 0.0.0.0 port 1 ssh2", + "test_enabled": false, + "expected": { + "username": "user_with-dash", + "date": null, + "ipAddress": "0.0.0.0", + "success": true, + "server": "192.168.56.1" + }, + "skip_date_assertion": true, + "id": 63 + }, + { + "category": "edge_case", + "host": "192.168.56.1", + "raw": "<14>sshd[1]: pam_unix(sshd:auth): authentication failure; login= uid=0 euid=0 tty=ssh ruser= rhost=255.255.255.255 user=Z", + "test_enabled": false, + "expected": { + "username": "Z", + "date": null, + "ipAddress": "255.255.255.255", + "success": false, + "server": "192.168.56.1" + }, + "skip_date_assertion": true, + "id": 64 + }, + { + "category": "edge_case", + "host": "10.42.10.2", + "raw": "<14>sshd[1]: Accepted publickey for UPPER from 10.42.10.2 port 2005 ssh2 DATE_TIME 2013-01-01 00:00:00 HOST srv-01", + "test_enabled": true, + "expected": { + "username": "UPPER", + "date": "2013-01-01 00:00:00", + "ipAddress": "10.42.10.2", + "success": true, + "server": "srv-01" + }, + "id": 65 + }, + { + "category": "edge_case", + "host": "192.168.56.1", + "raw": "<14> sshd[1]: Accepted publickey for spaced from 10.42.10.2 port 2005 ssh2", + "test_enabled": false, + "expected": null, + "id": 66 + } + ], + "description": "Syslog parser golden corpus for hacklog parse.py behavioral baseline" +} \ No newline at end of file diff --git a/tests/parse_test.py b/tests/parse_test.py index d86c41a..2e07203 100644 --- a/tests/parse_test.py +++ b/tests/parse_test.py @@ -1,63 +1,118 @@ -import unittest -from compat import _Compat import sys -from parse import Parser -from entities import * -from server import SyslogServer -import re - -parse = None -server = SyslogServer() -default = None - -class ParserTests(unittest.TestCase, _Compat): - - global parse - server = SyslogServer() - server.parceConfig('serverTest.conf') - - if server.testEnabled: - successPattern = 'Accepted\s+publickey\s+for\s+([0-9a-zA-Z_-]+)\s+from\s+(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\s+port\s+(\d{1,4})+\s+ssh2+\s+DATE_TIME\s+(\d{1,4}-\d{1,2}-\d{1,2}\s+\d{2}:\d{2}:\d{2})\s+HOST\s+([\w\+%\-& ]+)' - failurePattern = 'pam_unix\(sshd:auth\):\s+authentication\s+failure\;\s+login=\s+uid=0\s+euid=0\s+tty=ssh+\s+ruser=+\s+rhost=(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\s+user=([0-9a-zA-Z_-]+)\s+DATE_TIME\s+(\d{1,4}-\d{1,2}-\d{1,2}\s+\d{2}:\d{2}:\d{2})\s+HOST\s+([\w\+%\-& ]+)' - else: - successPattern = default - failurePattern = default +import unittest +from pathlib import Path - parse = Parser( successPattern, failurePattern) +_TESTS_DIR = Path(__file__).resolve().parent +_HACKLOG_DIR = _TESTS_DIR.parent / "hacklog" +for _path in (_TESTS_DIR, _HACKLOG_DIR, _TESTS_DIR.parent): + if str(_path) not in sys.path: + sys.path.insert(0, str(_path)) +from entities import EventLog, SyslogMsg +from parse import Parser +from server import SyslogServer +_server = SyslogServer() +_server.parse_config(str(_TESTS_DIR / "serverTest.conf")) + +if _server.test_enabled: + _success_pattern = ( + r"Accepted\s+publickey\s+for\s+([0-9a-zA-Z_-]+)\s+from\s+" + r"(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\s+port\s+(\d{1,4})+\s+ssh2+\s+" + r"DATE_TIME\s+(\d{1,4}-\d{1,2}-\d{1,2}\s+\d{2}:\d{2}:\d{2})\s+HOST\s+([\w\+%\-& ]+)" + ) + _failure_pattern = ( + r"pam_unix\(sshd:auth\):\s+authentication\s+failure\;\s+login=\s+uid=0\s+" + r"euid=0\s+tty=ssh+\s+ruser=+\s+rhost=(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\s+" + r"user=([0-9a-zA-Z_-]+)\s+DATE_TIME\s+(\d{1,4}-\d{1,2}-\d{1,2}\s+\d{2}:\d{2}:\d{2})\s+" + r"HOST\s+([\w\+%\-& ]+)" + ) +else: + _success_pattern = None + _failure_pattern = None + +_parser = Parser(_success_pattern, _failure_pattern) + + +class ParserTests(unittest.TestCase): def test_starting_out(self): self.assertEqual(1, 1) - if server.testEnabled: + if _server.test_enabled: + def test_parse_line_success_with_date_ip(self): - sysLogMessage = SyslogMsg("<14>sshd[4105]: Accepted publickey for kantselovich from 10.42.10.2 port 7786 ssh2 DATE_TIME 2013-09-23 11:16:48 HOST ae1-app80-prd", "192.168.56.1") - self.assertIsInstance(parse.parseLogLine(sysLogMessage), EventLog) + syslog_message = SyslogMsg( + "<14>sshd[4105]: Accepted publickey for kantselovich from 10.42.10.2 " + "port 7786 ssh2 DATE_TIME 2013-09-23 11:16:48 HOST ae1-app80-prd", + "192.168.56.1", + ) + self.assertIsInstance(_parser.parse_log_line(syslog_message), EventLog) def test_parse_line_failure_with_date_ip(self): - sysLogMessage = SyslogMsg("<14>sshd[4105]: pam_unix(sshd:auth): authentication failure; login= uid=0 euid=0 tty=ssh ruser= rhost=10.42.28.46 user=dchiu DATE_TIME 2013-09-23 11:52:30 HOST ae1-app80-prd", "192.168.56.1") - self.assertIsInstance(parse.parseLogLine(sysLogMessage), EventLog) + syslog_message = SyslogMsg( + "<14>sshd[4105]: pam_unix(sshd:auth): authentication failure; login= " + "uid=0 euid=0 tty=ssh ruser= rhost=10.42.28.46 user=dchiu " + "DATE_TIME 2013-09-23 11:52:30 HOST ae1-app80-prd", + "192.168.56.1", + ) + self.assertIsInstance(_parser.parse_log_line(syslog_message), EventLog) else: + def test_parse_line_success(self): - sysLogMessage = SyslogMsg("<14>sshd[3070]: Accepted publickey for kantselovich from 10.42.10.2 port 2005 ssh2", "192.168.56.1") - self.assertIsInstance(parse.parseLogLine(sysLogMessage), EventLog) + syslog_message = SyslogMsg( + "<14>sshd[3070]: Accepted publickey for kantselovich from 10.42.10.2 " + "port 2005 ssh2", + "192.168.56.1", + ) + self.assertIsInstance(_parser.parse_log_line(syslog_message), EventLog) def test_parse_line_failure(self): - sysLogMessage = SyslogMsg("<14>sshd[3070]: pam_unix(sshd:auth): authentication failure; login= uid=0 euid=0 tty=ssh ruser= rhost=10.42.10.22 user=msacks", "192.168.56.1") - self.assertIsInstance(parse.parseLogLine(sysLogMessage), EventLog) - - def test_parse_windows_Logs(self): - sysLogMessage = SyslogMsg("<14>Oct 10 14:26:09 USERNAME-DEV-VM Security-Auditing: 4624: AUDIT_SUCCESS An account was successfully logged on. Subject: Security ID: S-1-5-18 Account Name: USERNAME-DEV-VM$ Account Domain: WORKGROUP Logon ID: 0x3e7 Logon Type: 2 New Logon: Security ID: S-1-5-21-1223658549-3667468651-3388596622-1001 Account Name: developer Account Domain: username-dev-vm Logon ID: 0x8b32b5 Logon GUID: {00000000-0000-0000-0000-000000000000} Process Information: Process ID: 0x820 Process Name: C:\Windows\System32\winlogon.exe Network Information: Workstation Name: USERNAME-DEV-VM Source Network Address: 127.0.0.1 Source Port: 0 Detailed Authentication Information: Logon Process: User32 Authentication Package: Negotiate Transited Services: - Package Name (NTLM only): - Key Length: 0 This event is generated when a logon session is created. It is generated on the computer that was accessed. The subject fields indicate the account on the local system which requested the logon. This is most commonly a service such as the Server service, or a local process such as Winlogon.exe or Services.exe. The logon type field indicates the kind of logon that occurred. The most common types are 2 (interactive) and 3 (network). The New Logon fields indicate the account for whom the new logon was created, i.e. the account that was logged on. The network fields indicate where a remote logon request originated. Workstation name is not always available and may be left blank in some cases. The authentication information fields provide detailed information about this specific logon request. - Logon GUID is a unique identifier that can be used to correlate this event with a KDC event. - Transited services indicate which intermediate services have participated in this logon request. - Package name indicates which sub-protocol was used among the NTLM protocols. - Key length indicates the length of the generated session key. This will be 0 if no session key was requested.", "192.168.56.1") - self.assertIsInstance(parse.parseLogLine(sysLogMessage), EventLog) - - + syslog_message = SyslogMsg( + "<14>sshd[3070]: pam_unix(sshd:auth): authentication failure; login= " + "uid=0 euid=0 tty=ssh ruser= rhost=10.42.10.22 user=msacks", + "192.168.56.1", + ) + self.assertIsInstance(_parser.parse_log_line(syslog_message), EventLog) + + def test_parse_windows_logs(self): + syslog_message = SyslogMsg( + "<14>Oct 10 14:26:09 USERNAME-DEV-VM Security-Auditing: 4624: AUDIT_SUCCESS " + "An account was successfully logged on. Subject: Security ID: S-1-5-18 " + "Account Name: USERNAME-DEV-VM$ Account Domain: WORKGROUP Logon ID: 0x3e7 " + "Logon Type: 2 New Logon: Security ID: " + "S-1-5-21-1223658549-3667468651-3388596622-1001 Account Name: developer " + "Account Domain: username-dev-vm Logon ID: 0x8b32b5 Logon GUID: " + "{00000000-0000-0000-0000-000000000000} Process Information: Process ID: " + "0x820 Process Name: C:\\Windows\\System32\\winlogon.exe Network Information: " + "Workstation Name: USERNAME-DEV-VM Source Network Address: 127.0.0.1 " + "Source Port: 0 Detailed Authentication Information: Logon Process: User32 " + "Authentication Package: Negotiate Transited Services: - Package Name " + "(NTLM only): - Key Length: 0 This event is generated when a logon session " + "is created. It is generated on the computer that was accessed. The subject " + "fields indicate the account on the local system which requested the logon. " + "This is most commonly a service such as the Server service, or a local " + "process such as Winlogon.exe or Services.exe. The logon type field " + "indicates the kind of logon that occurred. The most common types are 2 " + "(interactive) and 3 (network). The New Logon fields indicate the account " + "for whom the new logon was created, i.e. the account that was logged on. " + "The network fields indicate where a remote logon request originated. " + "Workstation name is not always available and may be left blank in some " + "cases. The authentication information fields provide detailed information " + "about this specific logon request. - Logon GUID is a unique identifier " + "that can be used to correlate this event with a KDC event. - Transited " + "services indicate which intermediate services have participated in this " + "logon request. - Package name indicates which sub-protocol was used among " + "the NTLM protocols. - Key length indicates the length of the generated " + "session key. This will be 0 if no session key was requested.", + "192.168.56.1", + ) + self.assertIsInstance(_parser.parse_log_line(syslog_message), EventLog) def main(): - server.parceConfig('serverTest.conf') unittest.main() + if __name__ == "__main__": main() - diff --git a/tests/services_test.py b/tests/services_test.py index 0af268a..96836f1 100644 --- a/tests/services_test.py +++ b/tests/services_test.py @@ -1,92 +1,113 @@ -import unittest -from compat import _Compat -from mockito import mock, when, verify, any import sys -from entities import * -from services import * -import re +import unittest from datetime import datetime - -emailService = EmailService(MailConf(emailTest=False)) -updateService = UpdateService() - -class ServiceTests(unittest.TestCase, _Compat): - +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +_TESTS_DIR = Path(__file__).resolve().parent +_HACKLOG_DIR = _TESTS_DIR.parent / "hacklog" +for _path in (_TESTS_DIR, _HACKLOG_DIR, _TESTS_DIR.parent): + if str(_path) not in sys.path: + sys.path.insert(0, str(_path)) + +from alerting import AlertService +from entities import EventLog, Profile, ProfileType, User +from services import UpdateService + +try: + from hacklog.config import SmtpConfig +except ImportError: + from config import SmtpConfig + +from pydantic import SecretStr + +_smtp_config = SmtpConfig( + host="smtp.example.com", + port=587, + username="test@example.com", + password=SecretStr("test-password"), + sender="alerts@example.com", + recipient="soc@example.com", + use_tls=True, +) +email_service = AlertService(_smtp_config) +update_service = UpdateService() + + +class ServiceTests(unittest.TestCase): def setUp(self): - self._eventLog = EventLog(datetime.now(), 'nrhine', '1.2.3.4', True, 'prod') - self._user = User('nrhine', datetime.now(), 10) - self._day = Days(datetime.now(), 'nrhine', {'1.2.3.5':1}, 1 ) - self._hour = Hours(datetime.now(), 'nrhine', {}, 0) - self._server = Servers(datetime.now(), 'nrhine', {}, 0) - self._ipAddr = IpAddress(datetime.now(), 'nrhine', {}, 0) - updateService._genericDao = mock() - updateService._userDao = mock() - updateService._daysDao = mock() - updateService._hoursDao = mock() - updateService._serversDao = mock() - updateService._ipAddressDao = mock() - emailService._smtpSend = mock() + self._event_log = EventLog(datetime.now(), "nrhine", "1.2.3.4", True, "prod") + self._user = User("nrhine", datetime.now(), 10) + self._day = Profile( + datetime.now(), "nrhine", ProfileType.DAYS, {"1.2.3.5": 1}, 1 + ) + self._hour = Profile(datetime.now(), "nrhine", ProfileType.HOURS, {}, 0) + self._server = Profile(datetime.now(), "nrhine", ProfileType.SERVER, {}, 0) + self._ip_addr = Profile(datetime.now(), "nrhine", ProfileType.IP_ADDRESS, {}, 0) + update_service._profile_repository = MagicMock() + update_service._user_repository = MagicMock() + update_service._audit_repository = MagicMock() + self._smtp_sender = AsyncMock() + email_service._smtp_sender = self._smtp_sender def test_email_send(self): - when(emailService.mailServer).connect().thenReturn(True) - when(emailService.mailServer).sendmail().thenReturn(True) - emailService.sendEmailAlert(self._user, self._eventLog) - when(emailService._smtpSend).sendmail(any()) - verify(emailService._smtpSend, times=1).sendmail(any()) + email_service.send_email_alert(self._user, self._event_log) + self._smtp_sender.assert_awaited_once() def test_update_day_new_user(self): - when(updateService._daysDao).getProfileByUser(self._eventLog.username).thenReturn(None) - freq = updateService.updateAndReturnDayFreqForUser(self._eventLog) - self.assertIsInstance(freq, float) + update_service._profile_repository.get_profile.return_value = None + freq = update_service.update_and_return_day_freq_for_user(self._event_log) + self.assertIsInstance(freq, float) def test_update_day_old_user(self): - when(updateService._daysDao).getProfileByUser(self._eventLog.username).thenReturn(self._day) - freq = updateService.updateAndReturnDayFreqForUser(self._eventLog) - self.assertIsInstance(freq, float) + update_service._profile_repository.get_profile.return_value = self._day + freq = update_service.update_and_return_day_freq_for_user(self._event_log) + self.assertIsInstance(freq, float) def test_update_hour_new_user(self): - when(updateService._hoursDao).getProfileByUser(self._eventLog.username).thenReturn(None) - freq = updateService.updateAndReturnHourFreqForUser(self._eventLog) - self.assertIsInstance(freq, float) + update_service._profile_repository.get_profile.return_value = None + freq = update_service.update_and_return_hour_freq_for_user(self._event_log) + self.assertIsInstance(freq, float) def test_update_hour_old_user(self): - when(updateService._hoursDao).getProfileByUser(self._eventLog.username).thenReturn(self._hour) - freq = updateService.updateAndReturnHourFreqForUser(self._eventLog) - self.assertIsInstance(freq, float) + update_service._profile_repository.get_profile.return_value = self._hour + freq = update_service.update_and_return_hour_freq_for_user(self._event_log) + self.assertIsInstance(freq, float) def test_update_server_new_user(self): - when(updateService._serverDao).getProfileByUser(self._eventLog.username).thenReturn(None) - freq = updateService.updateAndReturnServerFreqForUser(self._eventLog) - self.assertIsInstance(freq, float) + update_service._profile_repository.get_profile.return_value = None + freq = update_service.update_and_return_server_freq_for_user(self._event_log) + self.assertIsInstance(freq, float) def test_update_server_old_user(self): - when(updateService._daysDao).getProfileByUser(self._eventLog.username).thenReturn(self._server) - freq = updateService.updateAndReturnServerFreqForUser(self._eventLog) - self.assertIsInstance(freq, float) + update_service._profile_repository.get_profile.return_value = self._server + freq = update_service.update_and_return_server_freq_for_user(self._event_log) + self.assertIsInstance(freq, float) - def test_update_ipAddr_new_user(self): - when(updateService._ipAddressDao).getProfileByUser(self._eventLog.username).thenReturn(None) - freq = updateService.updateAndReturnIpFreqForUser(self._eventLog) - self.assertIsInstance(freq, float) + def test_update_ip_addr_new_user(self): + update_service._profile_repository.get_profile.return_value = None + freq = update_service.update_and_return_ip_freq_for_user(self._event_log) + self.assertIsInstance(freq, float) - def test_update_ipAddr_old_user(self): - when(updateService._ipAddressDao).getProfileByUser(self._eventLog.username).thenReturn(self._ipAddr) - freq = updateService.updateAndReturnIpFreqForUser(self._eventLog) - self.assertIsInstance(freq, float) + def test_update_ip_addr_old_user(self): + update_service._profile_repository.get_profile.return_value = self._ip_addr + freq = update_service.update_and_return_ip_freq_for_user(self._event_log) + self.assertIsInstance(freq, float) def test_fetch_user_no_existing(self): - when(updateService._userDao).getUserByName(self._eventLog.username).thenReturn(None) - user = updateService.fetchUser(self._eventLog) - self.assertIsInstance(user, User) + update_service._user_repository.get_by_username.return_value = None + user = update_service.fetch_user(self._event_log) + self.assertIsInstance(user, User) def test_fetch_user_existing(self): - when(updateService._userDao).getUserByName(self._eventLog.username).thenReturn(self._user) - user = updateService.fetchUser(self._eventLog) - self.assertIsInstance(user, User) + update_service._user_repository.get_by_username.return_value = self._user + user = update_service.fetch_user(self._event_log) + self.assertIsInstance(user, User) + def main(): unittest.main() + if __name__ == "__main__": main() - diff --git a/tests/test_alerting.py b/tests/test_alerting.py new file mode 100644 index 0000000..1299b8b --- /dev/null +++ b/tests/test_alerting.py @@ -0,0 +1,292 @@ +"""Unit tests for AlertService, CircuitBreaker, and retry logic.""" + +import asyncio +import json +import sys +from datetime import datetime +from pathlib import Path +from unittest.mock import AsyncMock + +import pytest +from aiosmtplib.errors import SMTPAuthenticationError, SMTPConnectError +from pydantic import SecretStr + +_TESTS_DIR = Path(__file__).resolve().parent +_HACKLOG_DIR = _TESTS_DIR.parent / "hacklog" +for _path in (_TESTS_DIR, _HACKLOG_DIR, _TESTS_DIR.parent): + if str(_path) not in sys.path: + sys.path.insert(0, str(_path)) + +from alerting import ( # noqa: E402 + AlertService, + CircuitBreaker, + CircuitState, + DeadLetterWriter, + build_alert_message, + is_transient_smtp_error, +) +from entities import EventLog, User # noqa: E402 + +try: + from hacklog.config import SmtpConfig +except ImportError: + from config import SmtpConfig + + +class FakeClock: + def __init__(self, start: float = 0.0) -> None: + self.current = start + + def __call__(self) -> float: + return self.current + + def advance(self, seconds: float) -> None: + self.current += seconds + + +@pytest.fixture +def smtp_config() -> SmtpConfig: + return SmtpConfig( + host="smtp.example.com", + port=587, + username="test@example.com", + password=SecretStr("test-password"), + sender="alerts@example.com", + recipient="soc@example.com", + use_tls=True, + ) + + +@pytest.fixture +def event_log() -> EventLog: + return EventLog( + datetime(2026, 1, 15, 10, 30, 0), "nrhine", "10.0.0.1", False, "prod-host" + ) + + +@pytest.fixture +def user() -> User: + return User("nrhine", datetime(2026, 1, 15, 10, 30, 0), 75) + + +@pytest.fixture +def dead_letter_path(tmp_path: Path) -> Path: + return tmp_path / "dead_letter.jsonl" + + +@pytest.fixture +def success_smtp_sender() -> AsyncMock: + return AsyncMock() + + +@pytest.fixture +def transient_failure_smtp_sender() -> AsyncMock: + sender = AsyncMock( + side_effect=[ + SMTPConnectError("connection reset"), + SMTPConnectError("connection reset"), + None, + ] + ) + return sender + + +@pytest.fixture +def permanent_failure_smtp_sender() -> AsyncMock: + sender = AsyncMock(side_effect=SMTPAuthenticationError(535, "invalid credentials")) + return sender + + +@pytest.mark.asyncio +async def test_circuit_breaker_closed_to_open_after_five_failures() -> None: + breaker = CircuitBreaker(failure_threshold=5) + for _ in range(4): + await breaker.record_failure() + assert breaker.state == CircuitState.CLOSED + + await breaker.record_failure() + assert breaker.state == CircuitState.OPEN + assert not await breaker.allow_request() + + +@pytest.mark.asyncio +async def test_circuit_breaker_open_to_half_open_after_timeout() -> None: + clock = FakeClock() + breaker = CircuitBreaker(failure_threshold=1, reset_timeout=60.0, clock=clock) + await breaker.record_failure() + assert breaker.state == CircuitState.OPEN + assert not await breaker.allow_request() + + clock.advance(60.0) + assert await breaker.allow_request() + assert breaker.state == CircuitState.HALF_OPEN + + +@pytest.mark.asyncio +async def test_circuit_breaker_half_open_to_closed_on_success() -> None: + clock = FakeClock() + breaker = CircuitBreaker(failure_threshold=1, reset_timeout=60.0, clock=clock) + await breaker.record_failure() + clock.advance(60.0) + assert await breaker.allow_request() + await breaker.record_success() + assert breaker.state == CircuitState.CLOSED + + +@pytest.mark.asyncio +async def test_circuit_breaker_half_open_rejects_second_probe() -> None: + clock = FakeClock() + breaker = CircuitBreaker(failure_threshold=1, reset_timeout=60.0, clock=clock) + await breaker.record_failure() + clock.advance(60.0) + assert await breaker.allow_request() + assert not await breaker.allow_request() + + +@pytest.mark.asyncio +async def test_circuit_breaker_half_open_to_open_on_probe_failure() -> None: + clock = FakeClock() + breaker = CircuitBreaker(failure_threshold=1, reset_timeout=60.0, clock=clock) + await breaker.record_failure() + clock.advance(60.0) + assert await breaker.allow_request() + await breaker.record_failure() + assert breaker.state == CircuitState.OPEN + + +@pytest.mark.asyncio +async def test_alert_service_retries_transient_failure( + smtp_config: SmtpConfig, + user: User, + event_log: EventLog, + transient_failure_smtp_sender: AsyncMock, + dead_letter_path: Path, +) -> None: + service = AlertService( + smtp_config, + smtp_sender=transient_failure_smtp_sender, + dead_letter_path=dead_letter_path, + retry_base_delay_seconds=0.01, + ) + await service.send_alert(user, event_log) + assert transient_failure_smtp_sender.await_count == 3 + assert not dead_letter_path.exists() + + +@pytest.mark.asyncio +async def test_alert_service_does_not_retry_permanent_failure( + smtp_config: SmtpConfig, + user: User, + event_log: EventLog, + permanent_failure_smtp_sender: AsyncMock, + dead_letter_path: Path, +) -> None: + service = AlertService( + smtp_config, + smtp_sender=permanent_failure_smtp_sender, + dead_letter_path=dead_letter_path, + retry_base_delay_seconds=0.01, + ) + await service.send_alert(user, event_log) + assert permanent_failure_smtp_sender.await_count == 1 + assert dead_letter_path.exists() + payload = json.loads(dead_letter_path.read_text(encoding="utf-8").strip()) + assert payload["username"] == user.username + assert payload["server"] == event_log.server + + +@pytest.mark.asyncio +async def test_alert_service_success_logs_and_closes_circuit( + smtp_config: SmtpConfig, + user: User, + event_log: EventLog, + success_smtp_sender: AsyncMock, +) -> None: + breaker = CircuitBreaker(failure_threshold=5) + service = AlertService( + smtp_config, + circuit_breaker=breaker, + smtp_sender=success_smtp_sender, + ) + await service.send_alert(user, event_log) + success_smtp_sender.assert_awaited_once() + assert breaker.state == CircuitState.CLOSED + + +@pytest.mark.asyncio +async def test_alert_service_writes_dead_letter_when_circuit_open( + smtp_config: SmtpConfig, + user: User, + event_log: EventLog, + dead_letter_path: Path, +) -> None: + breaker = CircuitBreaker(failure_threshold=1) + await breaker.record_failure() + service = AlertService( + smtp_config, + circuit_breaker=breaker, + dead_letter_path=dead_letter_path, + smtp_sender=AsyncMock(), + ) + await service.send_alert(user, event_log) + assert dead_letter_path.exists() + payload = json.loads(dead_letter_path.read_text(encoding="utf-8").strip()) + assert payload["reason"] == "circuit_open" + + +def test_build_alert_message_includes_required_fields( + user: User, event_log: EventLog +) -> None: + message = build_alert_message( + user, + event_log, + sender="alerts@example.com", + recipient="soc@example.com", + ) + body = message.get_payload()[0].get_payload() + assert user.username in body + assert event_log.server in body + assert str(user.score) in body + assert "2026-01-15" in body + + +def test_is_transient_smtp_error_classification() -> None: + assert is_transient_smtp_error(SMTPConnectError("timeout")) + assert not is_transient_smtp_error(SMTPAuthenticationError(535, "bad auth")) + + +@pytest.mark.asyncio +async def test_dead_letter_writer_rotates_when_max_size_exceeded( + tmp_path: Path, +) -> None: + path = tmp_path / "dead_letter.jsonl" + writer = DeadLetterWriter(path, max_bytes=32) + await writer.write({"username": "a", "server": "s1", "score": 1, "timestamp": "t"}) + await writer.write({"username": "b", "server": "s2", "score": 2, "timestamp": "t"}) + assert path.exists() + rotated_files = list(tmp_path.glob("dead_letter.*.jsonl")) + assert len(rotated_files) == 1 + + +def test_send_email_alert_sync_wrapper( + smtp_config: SmtpConfig, + user: User, + event_log: EventLog, +) -> None: + sender = AsyncMock() + service = AlertService(smtp_config, smtp_sender=sender) + service.send_email_alert(user, event_log) + sender.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_send_email_alert_schedules_task_in_running_loop( + smtp_config: SmtpConfig, + user: User, + event_log: EventLog, +) -> None: + sender = AsyncMock() + service = AlertService(smtp_config, smtp_sender=sender) + service.send_email_alert(user, event_log) + await asyncio.sleep(0) + sender.assert_awaited_once() diff --git a/tests/test_audit.py b/tests/test_audit.py new file mode 100644 index 0000000..e77fa71 --- /dev/null +++ b/tests/test_audit.py @@ -0,0 +1,435 @@ +"""Tests for AuditRecord entity, AuditRepository, and audit integration.""" + +import sys +from datetime import UTC, datetime +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest +from pydantic import SecretStr +from sqlalchemy import create_engine, select +from sqlalchemy.orm import sessionmaker + +_TESTS_DIR = Path(__file__).resolve().parent +_HACKLOG_DIR = _TESTS_DIR.parent / "hacklog" +for _path in (_HACKLOG_DIR, _TESTS_DIR.parent): + if str(_path) not in sys.path: + sys.path.insert(0, str(_path)) + +from hacklog.alerting import AlertService # noqa: E402 +from hacklog.config import SmtpConfig # noqa: E402 +from hacklog.entities import AuditRecord, EventLog, User, create_tables # noqa: E402 +from hacklog.repositories import AuditRepository # noqa: E402 +from hacklog.scoring import ScoringEngine # noqa: E402 + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def session_factory(tmp_path: Path): + engine = create_engine(f"sqlite:///{tmp_path / 'audit_test.db'}") + create_tables(engine) + factory = sessionmaker( + bind=engine, autoflush=True, autocommit=False, expire_on_commit=False + ) + yield factory + engine.dispose() + + +@pytest.fixture +def audit_repository(session_factory) -> AuditRepository: + return AuditRepository(session_factory) + + +@pytest.fixture +def event_log() -> EventLog: + return EventLog( + datetime(2026, 3, 10, 14, 0, 0), "testuser", "10.0.0.5", False, "prod-host" + ) + + +@pytest.fixture +def user() -> User: + return User("testuser", datetime(2026, 3, 10, 14, 0, 0), 0) + + +@pytest.fixture +def smtp_config() -> SmtpConfig: + return SmtpConfig( + host="smtp.example.com", + port=587, + username="test@example.com", + password=SecretStr("test-password"), + sender="alerts@example.com", + recipient="soc@example.com", + use_tls=True, + ) + + +# --------------------------------------------------------------------------- +# AuditRecord entity tests +# --------------------------------------------------------------------------- + + +def test_audit_record_fields_stored_correctly( + audit_repository, session_factory +) -> None: + ts = datetime(2026, 3, 10, 14, 0, 0, tzinfo=UTC) + record = AuditRecord( + timestamp=ts, + actor="testuser", + source_ip="10.0.0.5", + resource="prod-host", + action="score_calculated", + outcome="42", + details={"total_score": 42.0, "success_score": 35.0}, + ) + audit_repository.save_audit_record(record) + + with session_factory() as session: + loaded = session.execute(select(AuditRecord)).scalars().first() + + assert loaded is not None + assert loaded.actor == "testuser" + assert loaded.source_ip == "10.0.0.5" + assert loaded.resource == "prod-host" + assert loaded.action == "score_calculated" + assert loaded.outcome == "42" + assert loaded.details["total_score"] == 42.0 + assert loaded.id is not None # auto-increment primary key + + +def test_audit_record_id_autoincrement(audit_repository, session_factory) -> None: + for i in range(3): + record = AuditRecord( + timestamp=datetime(2026, 3, 10, 14, i, 0), + actor="user", + source_ip="10.0.0.1", + resource="server", + action="score_calculated", + outcome=str(i), + ) + audit_repository.save_audit_record(record) + + with session_factory() as session: + records = session.execute(select(AuditRecord)).scalars().all() + + assert len(records) == 3 + ids = [r.id for r in records] + assert len(set(ids)) == 3 # all unique + + +# --------------------------------------------------------------------------- +# AuditRepository append-only tests +# --------------------------------------------------------------------------- + + +def test_audit_repository_has_no_update_method(audit_repository) -> None: + """AuditRepository must not expose an update method — append-only.""" + assert not hasattr(audit_repository, "update_audit_record") + assert not hasattr(audit_repository, "update") + + +def test_audit_repository_has_no_delete_method(audit_repository) -> None: + """AuditRepository must not expose a delete method — append-only.""" + assert not hasattr(audit_repository, "delete_audit_record") + assert not hasattr(audit_repository, "delete") + + +def test_audit_repository_save_audit_record_persists( + audit_repository, session_factory +) -> None: + record = AuditRecord( + timestamp=datetime(2026, 3, 10, 15, 0, 0), + actor="alice", + source_ip="192.168.1.1", + resource="app-server", + action="alert_sent", + outcome="alert_sent", + details={"reason": "smtp_success", "score": 55}, + ) + audit_repository.save_audit_record(record) + + with session_factory() as session: + rows = session.execute(select(AuditRecord)).scalars().all() + + assert len(rows) == 1 + assert rows[0].action == "alert_sent" + + +# --------------------------------------------------------------------------- +# ScoringEngine audit integration tests +# --------------------------------------------------------------------------- + + +def _make_mock_services(user: User): + update_service = MagicMock() + alert_service = MagicMock() + update_service.fetch_user.return_value = user + update_service.update_and_return_hour_freq_for_user.return_value = 0.5 + update_service.update_and_return_day_freq_for_user.return_value = 0.5 + update_service.update_and_return_server_freq_for_user.return_value = 0.5 + update_service.update_and_return_ip_freq_for_user.return_value = 0.5 + update_service.update_user_scare_count.side_effect = lambda u: u + return update_service, alert_service + + +def test_scoring_engine_creates_audit_record_for_score_calculated( + audit_repository, session_factory, event_log, user +) -> None: + update_service, alert_service = _make_mock_services(user) + engine = ScoringEngine(update_service, alert_service, audit_repository) + engine.process_event_log(event_log) + + with session_factory() as session: + records = ( + session.execute( + select(AuditRecord).where(AuditRecord.action == "score_calculated") + ) + .scalars() + .all() + ) + + assert len(records) >= 1 + rec = records[0] + assert rec.actor == event_log.username + assert rec.source_ip == event_log.ip_address + assert rec.resource == event_log.server + assert rec.outcome is not None + assert rec.details is not None + assert "total_score" in rec.details + assert "alert_decision" in rec.details + + +def test_scoring_engine_audit_record_contains_all_dimension_scores( + audit_repository, session_factory, event_log, user +) -> None: + update_service, alert_service = _make_mock_services(user) + engine = ScoringEngine(update_service, alert_service, audit_repository) + engine.process_event_log(event_log) + + with session_factory() as session: + rec = ( + session.execute( + select(AuditRecord).where(AuditRecord.action == "score_calculated") + ) + .scalars() + .first() + ) + + assert rec is not None + for field in ( + "success_score", + "ip_location_score", + "server_score", + "ip_score", + "day_score", + "hour_score", + "total_score", + ): + assert field in rec.details, f"Missing dimension score: {field}" + + +def test_scoring_engine_scare_count_update_creates_audit_record( + audit_repository, session_factory, event_log +) -> None: + from entities import Threshold + + # User with scare_count=0 (below threshold), score will be > SCARY but < CRITICAL + user = User("testuser", datetime(2026, 3, 10, 14, 0, 0), 0) + user.scare_count = 0 + user.last_scare_date = datetime(2026, 3, 10, 14, 0, 0) + + update_service, alert_service = _make_mock_services(user) + update_service.update_user_scare_count.side_effect = lambda u: u + + engine = ScoringEngine(update_service, alert_service, audit_repository) + # Force a score that's SCARY but not CRITICAL + engine.calculate_new_score = MagicMock( # type: ignore[method-assign] + return_value=(Threshold.SCARY + 1, {"total_score": float(Threshold.SCARY + 1)}) + ) + engine.process_event_log(event_log) + + with session_factory() as session: + records = ( + session.execute( + select(AuditRecord).where(AuditRecord.action == "scare_count_updated") + ) + .scalars() + .all() + ) + + assert len(records) == 1 + + +def test_scoring_engine_scare_count_reset_creates_audit_record( + audit_repository, session_factory, event_log +) -> None: + + # User with old scare date so reset triggers + user = User("testuser", datetime(2026, 1, 1, 0, 0, 0), 0) + user.scare_count = 1 + user.last_scare_date = datetime(2026, 1, 1, 0, 0, 0) + + update_service, alert_service = _make_mock_services(user) + # event_log date is 2026-03-10, last_scare_date is 2026-01-01 → > 1 day diff + + engine = ScoringEngine(update_service, alert_service, audit_repository) + # Force a low score so reset path triggers + engine.calculate_new_score = MagicMock( # type: ignore[method-assign] + return_value=(5, {"total_score": 5.0}) + ) + engine.process_event_log(event_log) + + with session_factory() as session: + records = ( + session.execute( + select(AuditRecord).where(AuditRecord.action == "scare_count_reset") + ) + .scalars() + .all() + ) + + assert len(records) == 1 + + +# --------------------------------------------------------------------------- +# AlertService audit integration tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_alert_service_creates_audit_record_on_success( + audit_repository, session_factory, smtp_config, event_log, user +) -> None: + sender = AsyncMock() + service = AlertService( + smtp_config, + smtp_sender=sender, + audit_repository=audit_repository, + ) + await service.send_alert(user, event_log) + + with session_factory() as session: + records = ( + session.execute( + select(AuditRecord).where(AuditRecord.action == "alert_sent") + ) + .scalars() + .all() + ) + + assert len(records) == 1 + rec = records[0] + assert rec.actor == user.username + assert rec.resource == event_log.server + assert rec.details is not None + assert rec.details["reason"] == "smtp_success" + + +@pytest.mark.asyncio +async def test_alert_service_creates_audit_record_on_circuit_open( + audit_repository, session_factory, smtp_config, event_log, user +) -> None: + from alerting import CircuitBreaker + + breaker = CircuitBreaker(failure_threshold=1) + await breaker.record_failure() + service = AlertService( + smtp_config, + circuit_breaker=breaker, + smtp_sender=AsyncMock(), + audit_repository=audit_repository, + ) + await service.send_alert(user, event_log) + + with session_factory() as session: + records = ( + session.execute( + select(AuditRecord).where(AuditRecord.action == "alert_suppressed") + ) + .scalars() + .all() + ) + + assert len(records) == 1 + rec = records[0] + assert rec.details["reason"] == "circuit_open" + + +@pytest.mark.asyncio +async def test_alert_service_creates_audit_record_on_smtp_failure( + audit_repository, session_factory, smtp_config, event_log, user, tmp_path +) -> None: + from aiosmtplib.errors import SMTPAuthenticationError + + dead_letter = tmp_path / "dl.jsonl" + sender = AsyncMock(side_effect=SMTPAuthenticationError(535, "invalid credentials")) + service = AlertService( + smtp_config, + smtp_sender=sender, + dead_letter_path=dead_letter, + audit_repository=audit_repository, + ) + await service.send_alert(user, event_log) + + with session_factory() as session: + records = ( + session.execute( + select(AuditRecord).where(AuditRecord.action == "alert_suppressed") + ) + .scalars() + .all() + ) + + assert len(records) == 1 + + +# --------------------------------------------------------------------------- +# System integration test: full pipeline end-to-end +# --------------------------------------------------------------------------- + + +def test_full_pipeline_creates_audit_record_with_correct_fields( + audit_repository, session_factory +) -> None: + """Process an EventLog through the full scoring pipeline and verify audit record.""" + + event = EventLog( + datetime(2026, 4, 1, 9, 0, 0), + "integration-user", + "10.0.0.99", + False, + "int-host", + ) + user = User("integration-user", datetime(2026, 4, 1, 9, 0, 0), 0) + user.last_scare_date = datetime(2026, 4, 1, 9, 0, 0) + + update_service = MagicMock() + alert_service = MagicMock() + update_service.fetch_user.return_value = user + update_service.update_and_return_hour_freq_for_user.return_value = 0.5 + update_service.update_and_return_day_freq_for_user.return_value = 0.5 + update_service.update_and_return_server_freq_for_user.return_value = 0.5 + update_service.update_and_return_ip_freq_for_user.return_value = 0.5 + update_service.update_user_scare_count.side_effect = lambda u: u + + engine = ScoringEngine(update_service, alert_service, audit_repository) + engine.process_event_log(event) + + with session_factory() as session: + records = session.execute(select(AuditRecord)).scalars().all() + + assert len(records) >= 1 + rec = next(r for r in records if r.action == "score_calculated") + assert rec.actor == "integration-user" + assert rec.source_ip == "10.0.0.99" + assert rec.resource == "int-host" + assert rec.outcome is not None + assert rec.details is not None + assert "total_score" in rec.details + assert "alert_decision" in rec.details + # UTC timestamp is set + assert rec.timestamp is not None diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..1f97e99 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,181 @@ +"""Unit tests for hacklog.config.""" + +import pytest +from pydantic import ValidationError + +from hacklog.config import ScoringConfig, load_config + +LEGACY_WEIGHTS = { + "hours_weight": 10, + "days_weight": 10, + "server_weight": 15, + "success_weight": 35, + "vpn_weight": 0, + "internal_weight": 10, + "external_weight": 15, + "ip_weight": 15, +} + +LEGACY_THRESHOLDS = { + "critical_threshold": 50, + "scary_threshold": 30, + "scare_count_limit": 2, + "scare_date_expire_days": 1, +} + + +def _set_required_smtp_env( + monkeypatch: pytest.MonkeyPatch, + *, + include_host: bool = True, +) -> None: + monkeypatch.setenv("HACKLOG_SMTP_USER", "alerts@example.com") + monkeypatch.setenv("HACKLOG_SMTP_PASSWORD", "secret-password") + monkeypatch.setenv("HACKLOG_SMTP_SENDER", "alerts@example.com") + monkeypatch.setenv("HACKLOG_ALERT_RECIPIENT", "soc@example.com") + if include_host: + monkeypatch.setenv("HACKLOG_SMTP_HOST", "smtp.example.com") + monkeypatch.setenv("HACKLOG_SMTP_PORT", "587") + + +@pytest.fixture(autouse=True) +def isolated_hacklog_env(monkeypatch: pytest.MonkeyPatch) -> None: + for key in ( + "HACKLOG_SMTP_USER", + "HACKLOG_SMTP_PASSWORD", + "HACKLOG_SMTP_SENDER", + "HACKLOG_SMTP_HOST", + "HACKLOG_SMTP_PORT", + "HACKLOG_ALERT_RECIPIENT", + "HACKLOG_SYSLOG_PORT", + "HACKLOG_SCORING_HOURS_WEIGHT", + ): + monkeypatch.delenv(key, raising=False) + + +def test_scoring_defaults_match_legacy_constants() -> None: + scoring = ScoringConfig() + for field, expected in {**LEGACY_WEIGHTS, **LEGACY_THRESHOLDS}.items(): + assert getattr(scoring, field) == expected + + +def test_load_config_applies_scoring_defaults_with_required_smtp( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _set_required_smtp_env(monkeypatch) + config = load_config() + + for field, expected in {**LEGACY_WEIGHTS, **LEGACY_THRESHOLDS}.items(): + assert getattr(config.scoring, field) == expected + + +def test_env_var_override_for_smtp(monkeypatch: pytest.MonkeyPatch) -> None: + _set_required_smtp_env(monkeypatch) + monkeypatch.setenv("HACKLOG_SMTP_HOST", "mail.internal.example") + monkeypatch.setenv("HACKLOG_SMTP_PORT", "2525") + + config = load_config() + + assert config.smtp.host == "mail.internal.example" + assert config.smtp.port == 2525 + assert config.smtp.username == "alerts@example.com" + assert config.smtp.recipient == "soc@example.com" + + +def test_missing_smtp_password_fails_fast(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("HACKLOG_SMTP_USER", "alerts@example.com") + monkeypatch.setenv("HACKLOG_SMTP_SENDER", "alerts@example.com") + monkeypatch.setenv("HACKLOG_ALERT_RECIPIENT", "soc@example.com") + monkeypatch.delenv("HACKLOG_SMTP_PASSWORD", raising=False) + + with pytest.raises(ValidationError) as exc_info: + load_config() + + message = str(exc_info.value) + assert "HACKLOG_SMTP_PASSWORD" in message + + +def test_empty_smtp_password_fails_fast(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("HACKLOG_SMTP_USER", "alerts@example.com") + monkeypatch.setenv("HACKLOG_SMTP_PASSWORD", " ") + monkeypatch.setenv("HACKLOG_SMTP_SENDER", "alerts@example.com") + monkeypatch.setenv("HACKLOG_ALERT_RECIPIENT", "soc@example.com") + + with pytest.raises(ValidationError) as exc_info: + load_config() + + message = str(exc_info.value) + assert "HACKLOG_SMTP_PASSWORD" in message + assert "environment variable is required" in message + + +def test_invalid_port_raises_validation_error(monkeypatch: pytest.MonkeyPatch) -> None: + _set_required_smtp_env(monkeypatch) + monkeypatch.setenv("HACKLOG_SMTP_PORT", "-1") + + with pytest.raises(ValidationError) as exc_info: + load_config() + + assert "port" in str(exc_info.value).lower() + + +def test_invalid_scoring_weight_raises_validation_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _set_required_smtp_env(monkeypatch) + monkeypatch.setenv("HACKLOG_SCORING_HOURS_WEIGHT", "101") + + with pytest.raises(ValidationError) as exc_info: + load_config() + + assert "hours_weight" in str(exc_info.value) + + +def test_yaml_file_loading(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: + _set_required_smtp_env(monkeypatch, include_host=False) + yaml_path = tmp_path / "hacklog.yaml" + yaml_path.write_text( + "\n".join( + [ + "syslog:", + " bind_address: 0.0.0.0", + " port: 1514", + "scoring:", + " hours_weight: 12", + "smtp:", + " host: yaml-smtp.example", + ] + ), + encoding="utf-8", + ) + + config = load_config(yaml_path) + + assert config.syslog.bind_address == "0.0.0.0" + assert config.syslog.port == 1514 + assert config.scoring.hours_weight == 12 + assert config.smtp.host == "yaml-smtp.example" + + +def test_env_vars_override_yaml(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: + _set_required_smtp_env(monkeypatch) + monkeypatch.setenv("HACKLOG_SYSLOG_PORT", "9999") + monkeypatch.setenv("HACKLOG_SCORING_HOURS_WEIGHT", "20") + + yaml_path = tmp_path / "hacklog.yaml" + yaml_path.write_text( + "\n".join( + [ + "syslog:", + " port: 1514", + "scoring:", + " hours_weight: 12", + ] + ), + encoding="utf-8", + ) + + config = load_config(yaml_path) + + assert config.syslog.port == 9999 + assert config.scoring.hours_weight == 20 diff --git a/tests/test_dev_scripts.py b/tests/test_dev_scripts.py new file mode 100644 index 0000000..1f8b297 --- /dev/null +++ b/tests/test_dev_scripts.py @@ -0,0 +1,103 @@ +"""Tests for local developer run/stop scripts (WO-039, WO-042).""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = REPO_ROOT / "scripts" + + +@pytest.fixture +def run_sh() -> str: + return (SCRIPTS / "run.sh").read_text(encoding="utf-8") + + +@pytest.fixture +def stop_sh() -> str: + return (SCRIPTS / "stop.sh").read_text(encoding="utf-8") + + +def test_run_sh_has_correct_shebang() -> None: + first_line = (SCRIPTS / "run.sh").read_text(encoding="utf-8").splitlines()[0] + assert first_line == "#!/bin/sh" + + +def test_stop_sh_has_correct_shebang() -> None: + first_line = (SCRIPTS / "stop.sh").read_text(encoding="utf-8").splitlines()[0] + assert first_line == "#!/bin/sh" + + +def test_stop_sh_uses_sigterm_not_kill_dash_nine(stop_sh: str) -> None: + assert "kill -TERM" in stop_sh or "kill -15" in stop_sh + assert "kill -9" not in stop_sh + assert "kill -KILL" not in stop_sh + + +def test_run_sh_loads_env_and_configmanager(run_sh: str) -> None: + assert ".env" in run_sh + assert "HACKLOG_SMTP_USER" in run_sh + assert "ConfigManager" in run_sh + assert "conf/server.conf" in run_sh or "HACKLOG_CONFIG" in run_sh + + +def test_makefile_exposes_dev_targets() -> None: + makefile = (REPO_ROOT / "Makefile").read_text(encoding="utf-8") + for target in ("dev-start", "dev-stop", "dev-status", "dev-restart"): + assert f"{target}:" in makefile + + +def test_scripts_are_executable() -> None: + for name in ("run.sh", "stop.sh", "dev-status.sh"): + path = SCRIPTS / name + assert path.exists(), f"missing {name}" + assert path.stat().st_mode & 0o111, f"{name} should be executable" + + +def test_stop_sh_exits_cleanly_when_not_running(tmp_path: Path) -> None: + pidfile = tmp_path / "hacklog.pid" + script = SCRIPTS / "stop.sh" + env = {"HACKLOG_PIDFILE": str(pidfile)} + import os + import subprocess + + result = subprocess.run( + [str(script)], + cwd=REPO_ROOT, + env={**os.environ, **env}, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0 + assert "not running" in result.stdout.lower() + + +def test_legacy_hacklog_run_stop_scripts_removed() -> None: + """WO-042: crude hacklog/run.sh and hacklog/stop.sh must not exist.""" + assert not (REPO_ROOT / "hacklog" / "run.sh").exists() + assert not (REPO_ROOT / "hacklog" / "stop.sh").exists() + + +def test_modern_dev_tooling_replaces_legacy_scripts() -> None: + """WO-042: Makefile + scripts/ provide developer convenience.""" + assert (REPO_ROOT / "Makefile").exists() + assert (REPO_ROOT / "docker-compose.yml").exists() + assert (REPO_ROOT / "deploy" / "hacklog.service").exists() + for name in ("run.sh", "stop.sh"): + path = SCRIPTS / name + assert path.exists() + assert path.read_text(encoding="utf-8").splitlines()[0] == "#!/bin/sh" + + +def test_run_sh_does_not_use_grep_kill_pattern(run_sh: str) -> None: + assert "grep" not in run_sh + assert "kill -9" not in run_sh + + +def test_stop_sh_uses_pid_file_not_ps_grep(stop_sh: str) -> None: + assert "PIDFILE" in stop_sh or "pid" in stop_sh.lower() + assert "ps aux" not in stop_sh + assert "grep" not in stop_sh diff --git a/tests/test_e2e.py b/tests/test_e2e.py new file mode 100644 index 0000000..2afed92 --- /dev/null +++ b/tests/test_e2e.py @@ -0,0 +1,366 @@ +"""End-to-end integration tests for the full hacklog pipeline (WO-029).""" + +from __future__ import annotations + +import asyncio +import json +import socket +import sys +from datetime import datetime +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +from sqlalchemy import func, select + +_TESTS_DIR = Path(__file__).resolve().parent +_HACKLOG_DIR = _TESTS_DIR.parent / "hacklog" +for _path in (_HACKLOG_DIR, _TESTS_DIR.parent): + if str(_path) not in sys.path: + sys.path.insert(0, str(_path)) + +from hacklog.alerting import AlertService # noqa: E402 +from hacklog.config import SyslogConfig # noqa: E402 +from hacklog.entities import ( # noqa: E402 + EventLog, + Profile, + ProfileType, + Threshold, + User, + Weight, +) +from hacklog.parse import Parser # noqa: E402 +from hacklog.scoring import ScoringEngine # noqa: E402 +from hacklog.services import HourRangeEnum, UpdateService # noqa: E402 +from hacklog.syslog_server import ( # noqa: E402 + SyslogProtocol, + build_validator, + message_consumer, +) + +TOLERANCE = 1e-9 + +LINUX_FAILURE_SYSLOG = ( + b"<14>sshd[4105]: pam_unix(sshd:auth): authentication failure; login= " + b"uid=0 euid=0 tty=ssh ruser= rhost=203.0.113.50 user=e2euser" +) + +LINUX_SUCCESS_SYSLOG = ( + b"<14>sshd[3070]: Accepted publickey for e2euser from 10.42.10.2 port 2005 ssh2" +) + + +class E2EPipeline: + """Wire UDP ingestion, parsing, scoring, SQLite persistence, and alerting.""" + + def __init__( + self, + *, + session_factory, + scoring_engine: ScoringEngine, + syslog_config: SyslogConfig, + ) -> None: + self.session_factory = session_factory + self.scoring_engine = scoring_engine + self.syslog_config = syslog_config + self.parser = Parser() + self.queue: asyncio.Queue = asyncio.Queue(maxsize=1000) + self.running = True + self.transport = None + self.consumer_task: asyncio.Task | None = None + self.port: int = 0 + + async def start(self) -> None: + loop = asyncio.get_running_loop() + ready = asyncio.Event() + validator = build_validator(self.syslog_config) + + class _Listener(SyslogProtocol): + def connection_made(self, transport: asyncio.BaseTransport) -> None: + super().connection_made(transport) + ready.set() + + self.transport, _protocol = await loop.create_datagram_endpoint( + lambda: _Listener( + self.queue, + validator, + accepting=lambda: self.running, + ), + local_addr=(self.syslog_config.bind_address, 0), + ) + await ready.wait() + self.port = self.transport.get_extra_info("sockname")[1] + self.consumer_task = asyncio.create_task( + message_consumer( + self.queue, + self.parser, + self.scoring_engine.process_event_log, + running=lambda: self.running, + ) + ) + + async def stop(self) -> None: + self.running = False + await asyncio.sleep(0.15) + if self.transport is not None: + self.transport.close() + if self.consumer_task is not None: + await asyncio.wait_for(self.consumer_task, timeout=3) + + def send_udp(self, payload: bytes, source_host: str = "127.0.0.1") -> None: + client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + client.sendto(payload, (self.syslog_config.bind_address, self.port)) + client.close() + + def event_log_count(self) -> int: + with self.session_factory() as session: + return session.execute( + select(func.count()).select_from(EventLog) + ).scalar_one() + + def get_user(self, username: str) -> User | None: + with self.session_factory() as session: + return session.execute( + select(User).where(User.username == username) + ).scalar_one_or_none() + + +@pytest.fixture +async def e2e_pipeline(e2e_services, e2e_syslog_config): + scoring_engine, _update, _alert, _smtp = e2e_services + pipeline = E2EPipeline( + session_factory=scoring_engine._update_service._user_repository.session_factory, + scoring_engine=scoring_engine, + syslog_config=e2e_syslog_config, + ) + await pipeline.start() + yield pipeline + await pipeline.stop() + + +def _parse_golden_event(raw: dict) -> EventLog: + data = raw["input"] + return EventLog( + datetime.strptime(data["date"], "%Y-%m-%dT%H:%M:%S"), + data["username"], + data["ipAddress"], + data["success"], + data["server"], + ) + + +def _assert_close(actual: float, expected: float) -> None: + assert abs(actual - expected) <= TOLERANCE, f"expected {expected}, got {actual}" + + +@pytest.mark.asyncio +async def test_e2e_udp_parse_score_persist(e2e_pipeline: E2EPipeline) -> None: + e2e_pipeline.send_udp(LINUX_FAILURE_SYSLOG) + await asyncio.sleep(0.25) + assert e2e_pipeline.event_log_count() == 1 + user = e2e_pipeline.get_user("e2euser") + assert user is not None + assert user.score > 0 + + +@pytest.mark.asyncio +async def test_e2e_critical_score_triggers_alert( + e2e_pipeline: E2EPipeline, + mock_smtp_sender, +) -> None: + """Failure plus rare behavioral profiles pushes score above CRITICAL.""" + update_service = e2e_pipeline.scoring_engine._update_service + username = "alertuser" + now = datetime.now() + hour = now.hour + range_name = "morning" + for hour_range, name in zip( + [ + HourRangeEnum.EARLY, + HourRangeEnum.DAWN, + HourRangeEnum.MORNING, + HourRangeEnum.AFTERNOON, + HourRangeEnum.EVE, + HourRangeEnum.NIGHT, + ], + ["early", "dawn", "morning", "afternoon", "eve", "night"], + strict=False, + ): + if hour in hour_range: + range_name = name + break + + rare = 1 + total = 500 + update_service._profile_repository.save_profile( + Profile(now, username, ProfileType.HOURS, {range_name: rare}, total) + ) + update_service._profile_repository.save_profile( + Profile(now, username, ProfileType.DAYS, {now.strftime("%a"): rare}, total) + ) + update_service._profile_repository.save_profile( + Profile(now, username, ProfileType.SERVER, {"127.0.0.1": rare}, total) + ) + update_service._profile_repository.save_profile( + Profile(now, username, ProfileType.IP_ADDRESS, {"203.0.113.50": rare}, total) + ) + + e2e_pipeline.send_udp( + LINUX_FAILURE_SYSLOG.replace(b"e2euser", b"alertuser"), + ) + await asyncio.sleep(0.25) + mock_smtp_sender.assert_awaited() + + +@pytest.mark.asyncio +async def test_e2e_normal_score_does_not_alert( + e2e_pipeline: E2EPipeline, + mock_smtp_sender, +) -> None: + e2e_pipeline.send_udp(LINUX_SUCCESS_SYSLOG) + await asyncio.sleep(0.25) + mock_smtp_sender.assert_not_awaited() + user = e2e_pipeline.get_user("e2euser") + assert user is not None + assert user.score <= Threshold.SCARY + + +@pytest.mark.asyncio +async def test_e2e_scare_counter_escalation_triggers_alert( + sqlite_session_factory, + smtp_config, + mock_smtp_sender, +) -> None: + update_service = UpdateService(session_factory=sqlite_session_factory) + alert_service = AlertService(smtp_config, smtp_sender=mock_smtp_sender) + engine = ScoringEngine(update_service, alert_service) + + scary_score = Threshold.SCARY + 5 + user = User("scareuser", datetime(2026, 1, 15, 10, 0, 0), 0) + user.scare_count = 0 + update_service._user_repository.save(user) + + event = EventLog( + datetime(2026, 1, 15, 10, 0, 0), + "scareuser", + "203.0.113.9", + False, + "prod-host", + ) + + engine.calculate_new_score = MagicMock( # type: ignore[method-assign] + return_value=(scary_score, {"total_score": float(scary_score)}) + ) + + for _ in range(Threshold.SCARECOUNT): + engine.process_event_log(event) + await asyncio.sleep(0) + + engine.process_event_log(event) + await asyncio.sleep(0.05) + mock_smtp_sender.assert_awaited() + + +@pytest.mark.asyncio +async def test_e2e_ip_allowlist_rejects_non_allowlisted_source( + e2e_services, + mock_smtp_sender, +) -> None: + scoring_engine, _, _, _ = e2e_services + config = SyslogConfig( + bind_address="127.0.0.1", + allowed_cidrs=["10.0.0.0/8"], + rate_limit_per_source=100, + ) + pipeline = E2EPipeline( + session_factory=scoring_engine._update_service._user_repository.session_factory, + scoring_engine=scoring_engine, + syslog_config=config, + ) + await pipeline.start() + try: + pipeline.send_udp(LINUX_FAILURE_SYSLOG) + await asyncio.sleep(0.2) + assert pipeline.event_log_count() == 0 + finally: + await pipeline.stop() + + +@pytest.mark.asyncio +async def test_e2e_rate_limiting_drops_excess_messages(e2e_services) -> None: + scoring_engine, _, _, _ = e2e_services + config = SyslogConfig( + bind_address="127.0.0.1", + allowed_cidrs=[], + rate_limit_per_source=1, + ) + pipeline = E2EPipeline( + session_factory=scoring_engine._update_service._user_repository.session_factory, + scoring_engine=scoring_engine, + syslog_config=config, + ) + await pipeline.start() + try: + pipeline.send_udp(LINUX_FAILURE_SYSLOG) + pipeline.send_udp(LINUX_FAILURE_SYSLOG) + await asyncio.sleep(0.25) + assert pipeline.event_log_count() == 1 + finally: + await pipeline.stop() + + +def test_e2e_golden_corpus_scoring_parity(scoring_golden_events) -> None: + """All 527 WO-001 golden events match ScoringEngine.calculate_new_score.""" + update_service = MagicMock(spec=UpdateService) + alert_service = MagicMock() + engine = ScoringEngine(update_service, alert_service) + + for raw in scoring_golden_events: + event = _parse_golden_event(raw) + freqs = raw["frequencies"] + expected = raw["expected"] + update_service.update_and_return_hour_freq_for_user.return_value = freqs["hour"] + update_service.update_and_return_day_freq_for_user.return_value = freqs["day"] + update_service.update_and_return_server_freq_for_user.return_value = freqs[ + "server" + ] + update_service.update_and_return_ip_freq_for_user.return_value = freqs["ip"] + + success = engine.calculate_success_score(event.success) + ip_loc = engine.calculate_ip_location_score(event.ip_address) + hour = engine.calculate_subscore(freqs["hour"]) * Weight.HOURS + day = engine.calculate_subscore(freqs["day"]) * Weight.DAYS + server = engine.calculate_subscore(freqs["server"]) * Weight.SERVER + ip = engine.calculate_subscore(freqs["ip"]) * Weight.IP + + _assert_close(success, expected["success"]) + _assert_close(ip_loc, expected["ip_location"]) + _assert_close(hour, expected["hours"]) + _assert_close(day, expected["days"]) + _assert_close(server, expected["server"]) + _assert_close(ip, expected["ip"]) + + total, dims = engine.calculate_new_score(event) + _assert_close(dims["total_score"], expected["total"]) + + +@pytest.mark.asyncio +async def test_e2e_syslog_corpus_over_udp(e2e_pipeline: E2EPipeline) -> None: + """WO-002 syslog corpus messages parse and persist through the live UDP path.""" + corpus_path = _TESTS_DIR / "fixtures" / "syslog_corpus.json" + with corpus_path.open(encoding="utf-8") as handle: + corpus = json.load(handle) + + processed_before = e2e_pipeline.event_log_count() + sent = 0 + for entry in corpus["messages"][:10]: + raw = entry.get("raw") or entry.get("message") + if not raw: + continue + payload = raw.encode("utf-8") if isinstance(raw, str) else raw + e2e_pipeline.send_udp(payload) + sent += 1 + + await asyncio.sleep(0.5) + assert sent > 0 + assert e2e_pipeline.event_log_count() > processed_before diff --git a/tests/test_email_service.py b/tests/test_email_service.py new file mode 100644 index 0000000..2cee0cb --- /dev/null +++ b/tests/test_email_service.py @@ -0,0 +1,80 @@ +"""Unit tests for AlertService credential loading.""" + +import pytest +from pydantic import ValidationError + +from hacklog.alerting import AlertService +from hacklog.config import load_config, load_config_or_exit + + +def _set_test_smtp_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("HACKLOG_SMTP_USER", "test@example.com") + monkeypatch.setenv("HACKLOG_SMTP_PASSWORD", "test-password") + monkeypatch.setenv("HACKLOG_SMTP_SENDER", "alerts@example.com") + monkeypatch.setenv("HACKLOG_ALERT_RECIPIENT", "soc@example.com") + monkeypatch.setenv("HACKLOG_SMTP_HOST", "smtp.example.com") + monkeypatch.setenv("HACKLOG_SMTP_PORT", "587") + + +@pytest.fixture(autouse=True) +def isolated_smtp_env(monkeypatch: pytest.MonkeyPatch) -> None: + for key in ( + "HACKLOG_SMTP_USER", + "HACKLOG_SMTP_PASSWORD", + "HACKLOG_SMTP_SENDER", + "HACKLOG_ALERT_RECIPIENT", + "HACKLOG_SMTP_HOST", + "HACKLOG_SMTP_PORT", + ): + monkeypatch.delenv(key, raising=False) + + +def test_alert_service_initialization_succeeds_with_env_vars( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _set_test_smtp_env(monkeypatch) + smtp_config = load_config().smtp + + service = AlertService(smtp_config) + + assert service.from_address == "alerts@example.com" + assert service.recipient == "soc@example.com" + assert service.mail_server is None + + +def test_alert_service_initialization_fails_without_smtp_password( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("HACKLOG_SMTP_USER", "test@example.com") + monkeypatch.setenv("HACKLOG_SMTP_SENDER", "alerts@example.com") + monkeypatch.setenv("HACKLOG_ALERT_RECIPIENT", "soc@example.com") + monkeypatch.delenv("HACKLOG_SMTP_PASSWORD", raising=False) + + with pytest.raises(ValidationError) as exc_info: + load_config() + + assert "HACKLOG_SMTP_PASSWORD" in str(exc_info.value) + + +def test_startup_exits_when_smtp_password_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("HACKLOG_SMTP_USER", "test@example.com") + monkeypatch.setenv("HACKLOG_SMTP_SENDER", "alerts@example.com") + monkeypatch.setenv("HACKLOG_ALERT_RECIPIENT", "soc@example.com") + monkeypatch.delenv("HACKLOG_SMTP_PASSWORD", raising=False) + + with pytest.raises(SystemExit) as exc_info: + load_config_or_exit() + + assert ( + str(exc_info.value) == "HACKLOG_SMTP_PASSWORD environment variable is required" + ) + + +def test_alert_service_requires_smtp_config_object() -> None: + with pytest.raises(TypeError): + AlertService(None) + + with pytest.raises(TypeError): + AlertService(object()) diff --git a/tests/test_entities_json.py b/tests/test_entities_json.py new file mode 100644 index 0000000..7d4a801 --- /dev/null +++ b/tests/test_entities_json.py @@ -0,0 +1,104 @@ +"""Unit tests for JSON profile columns on the unified Profile entity.""" + +import json +import sys +from datetime import datetime +from pathlib import Path + +import pytest +from sqlalchemy import create_engine, select + +_TESTS_DIR = Path(__file__).resolve().parent +_HACKLOG_DIR = _TESTS_DIR.parent / "hacklog" +for _path in (_HACKLOG_DIR, _TESTS_DIR.parent): + if str(_path) not in sys.path: + sys.path.insert(0, str(_path)) + +from entities import Profile, ProfileType, create_tables # noqa: E402 +from session import Session # noqa: E402 + + +@pytest.fixture +def json_db_engine(tmp_path: Path): + db_file = tmp_path / "profiles.db" + engine = create_engine(f"sqlite:///{db_file}") + create_tables(engine) + Session.configure(bind=engine) + yield engine + engine.dispose() + + +PROFILE_FIXTURES = json.loads( + (Path(__file__).parent / "fixtures" / "profile_fixtures.json").read_text( + encoding="utf-8" + ) +) + +PROFILE_CASES = [ + (ProfileType.DAYS, "days"), + (ProfileType.HOURS, "hours"), + (ProfileType.SERVER, "servers"), + (ProfileType.IP_ADDRESS, "ipAddress"), +] + + +@pytest.mark.parametrize(("profile_type", "fixture_key"), PROFILE_CASES) +def test_profile_round_trips_through_json( + json_db_engine, + profile_type: ProfileType, + fixture_key: str, +) -> None: + profile_data = PROFILE_FIXTURES[fixture_key] + entity = Profile( + datetime(2026, 1, 15, 12, 0, 0), "nrhine", profile_type, profile_data, 0 + ) + + with Session() as session: + session.add(entity) + session.commit() + loaded = session.execute( + select(Profile).where( + Profile.username == "nrhine", + Profile.profile_type == profile_type.value, + ) + ).scalar_one() + assert loaded.profile == profile_data + + +@pytest.mark.parametrize(("profile_type", "fixture_key"), PROFILE_CASES) +def test_empty_profile_dict_round_trips( + json_db_engine, + profile_type: ProfileType, + fixture_key: str, +) -> None: + del fixture_key + entity = Profile(datetime(2026, 2, 1, 8, 0, 0), "empty-user", profile_type, {}, 0) + + with Session() as session: + session.add(entity) + session.commit() + loaded = session.execute( + select(Profile).where( + Profile.username == "empty-user", + Profile.profile_type == profile_type.value, + ) + ).scalar_one() + assert loaded.profile == {} + + +def test_days_profile_mon_tue_example(json_db_engine) -> None: + profile = {"Mon": 5, "Tue": 3} + entity = Profile( + datetime(2026, 3, 1, 0, 0, 0), "weekday-user", ProfileType.DAYS, profile, 8 + ) + + with Session() as session: + session.add(entity) + session.commit() + loaded = session.execute( + select(Profile).where( + Profile.username == "weekday-user", + Profile.profile_type == ProfileType.DAYS.value, + ) + ).scalar_one() + assert loaded.profile == {"Mon": 5, "Tue": 3} diff --git a/tests/test_logging_config.py b/tests/test_logging_config.py new file mode 100644 index 0000000..7937705 --- /dev/null +++ b/tests/test_logging_config.py @@ -0,0 +1,134 @@ +"""Unit tests for hacklog.logging_config.""" + +import json +import logging + +import pytest +import structlog +from pydantic.types import SecretStr + +from hacklog.logging_config import ( + clear_context, + configure_logging, + get_logger, + parse_json_log_line, + render_event_dict, +) + + +@pytest.fixture(autouse=True) +def reset_logging() -> None: + clear_context() + logging.getLogger().handlers.clear() + structlog.reset_defaults() + + +def test_structlog_configuration_produces_valid_json( + capsys: pytest.CaptureFixture[str], +) -> None: + configure_logging(level=logging.INFO) + logger = get_logger("test") + logger.info("configuration_check", operation="validate_json") + + line = capsys.readouterr().out.strip() + payload = parse_json_log_line(line) + + assert payload["event"] == "configuration_check" + assert payload["component"] == "test" + assert payload["operation"] == "validate_json" + assert "timestamp" in payload + assert payload["level"] == "info" + + +def test_render_event_dict_is_valid_json() -> None: + output = render_event_dict( + { + "event": "sample", + "component": "algorithm", + "operation": "calculate_score", + "level": "debug", + } + ) + payload = json.loads(output) + assert payload["component"] == "algorithm" + + +def test_scoring_operation_log_contains_expected_fields( + capsys: pytest.CaptureFixture[str], +) -> None: + configure_logging(level=logging.DEBUG) + logger = get_logger("algorithm") + logger.debug( + "score_calculated", + operation="calculate_score", + username="alice", + source_ip="10.0.0.5", + score=42, + ) + + payload = parse_json_log_line(capsys.readouterr().out.strip()) + + assert payload["component"] == "algorithm" + assert payload["operation"] == "calculate_score" + assert payload["username"] == "alice" + assert payload["source_ip"] == "10.0.0.5" + assert payload["score"] == 42 + + +def test_credentials_are_never_logged(capsys: pytest.CaptureFixture[str]) -> None: + configure_logging(level=logging.INFO) + logger = get_logger("smtp") + + secret_password = "SuperSecretSMTPPassword123" + logger.info( + "smtp_config_loaded", + operation="load_smtp_config", + host="smtp.example.com", + username="alerts@example.com", + password=SecretStr(secret_password), + smtp_password=secret_password, + ) + + output = capsys.readouterr().out + assert secret_password not in output + assert "SuperSecret" not in output + + payload = parse_json_log_line(output.strip()) + assert payload["password"] == "***REDACTED***" + assert payload["smtp_password"] == "***REDACTED***" + + +def test_pii_masking_redacts_debug_level_identifiers( + capsys: pytest.CaptureFixture[str], +) -> None: + configure_logging(level=logging.DEBUG, mask_pii=True) + logger = get_logger("algorithm") + logger.debug( + "score_calculated", + operation="calculate_score", + username="alice", + source_ip="10.0.0.5", + score=42, + ) + + payload = parse_json_log_line(capsys.readouterr().out.strip()) + assert payload["username"] != "alice" + assert payload["source_ip"] != "10.0.0.5" + + +def test_pii_not_masked_for_info_level_alert_logs( + capsys: pytest.CaptureFixture[str], +) -> None: + configure_logging(level=logging.INFO, mask_pii=True) + logger = get_logger("algorithm") + logger.info( + "alert_triggered", + operation="process_alert", + username="alice", + source_ip="10.0.0.5", + score=75, + ) + + payload = parse_json_log_line(capsys.readouterr().out.strip()) + assert payload["username"] == "alice" + assert payload["source_ip"] == "10.0.0.5" diff --git a/tests/test_metrics.py b/tests/test_metrics.py new file mode 100644 index 0000000..1c99a19 --- /dev/null +++ b/tests/test_metrics.py @@ -0,0 +1,130 @@ +"""Unit tests for hacklog.metrics.""" + +import re +import urllib.error +import urllib.request + +import pytest + +from hacklog.metrics import ( + alerts_sent_total, + db_operation_duration_seconds, + find_available_port, + get_metric_objects, + messages_dropped_total, + messages_parsed_total, + messages_received_total, + metrics_enabled, + queue_depth, + render_metrics, + reset_metrics_server_state_for_testing, + scores_calculated_total, + scoring_duration_seconds, + start_metrics_server, +) + + +@pytest.fixture(autouse=True) +def reset_metrics_state(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("HACKLOG_METRICS_ENABLED", raising=False) + monkeypatch.delenv("HACKLOG_METRICS_PORT", raising=False) + reset_metrics_server_state_for_testing() + + +def test_metric_objects_are_defined() -> None: + metrics = get_metric_objects() + assert set(metrics) == { + "messages_received_total", + "messages_dropped_total", + "messages_parsed_total", + "scoring_duration_seconds", + "scores_calculated_total", + "alerts_sent_total", + "queue_depth", + "db_operation_duration_seconds", + } + + +def test_metrics_can_be_incremented_and_observed() -> None: + messages_received_total.inc() + messages_dropped_total.labels(reason="rate_limit").inc() + messages_parsed_total.labels(format="syslog", status="success").inc() + scores_calculated_total.labels(decision="alert").inc() + scores_calculated_total.labels(decision="normal").inc() + alerts_sent_total.labels(status="success").inc() + alerts_sent_total.labels(status="failure").inc() + queue_depth.set(7) + + scoring_duration_seconds.observe(0.012) + db_operation_duration_seconds.labels(operation="save").observe(0.004) + + output = render_metrics().decode("utf-8") + assert "messages_received_total" in output + assert 'messages_dropped_total{reason="rate_limit"}' in output + assert 'messages_parsed_total{format="syslog",status="success"}' in output + assert 'scores_calculated_total{decision="alert"}' in output + assert 'scores_calculated_total{decision="normal"}' in output + assert 'alerts_sent_total{status="success"}' in output + assert 'alerts_sent_total{status="failure"}' in output + assert "queue_depth" in output + assert "scoring_duration_seconds" in output + assert 'operation="save"' in output + assert "db_operation_duration_seconds_bucket" in output + + +def test_render_metrics_returns_prometheus_exposition_format() -> None: + messages_received_total.inc(3) + output = render_metrics().decode("utf-8") + + assert re.search(r"^# HELP messages_received_total ", output, re.MULTILINE) + assert re.search(r"^# TYPE messages_received_total counter", output, re.MULTILINE) + assert re.search(r"^messages_received_total ", output, re.MULTILINE) + + +def test_metrics_server_disabled_by_default() -> None: + assert metrics_enabled() is False + assert start_metrics_server(port=find_available_port()) is None + + +def test_metrics_server_can_be_disabled_via_env( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("HACKLOG_METRICS_ENABLED", "false") + assert start_metrics_server(port=find_available_port(), enabled=None) is None + + +def test_metrics_endpoint_returns_prometheus_text( + monkeypatch: pytest.MonkeyPatch, +) -> None: + port = find_available_port() + monkeypatch.setenv("HACKLOG_METRICS_ENABLED", "true") + + started_port = start_metrics_server(port=port) + assert started_port == port + + messages_received_total.inc(2) + queue_depth.set(4) + + with urllib.request.urlopen( + f"http://127.0.0.1:{port}/metrics", timeout=2 + ) as response: + body = response.read().decode("utf-8") + content_type = response.headers.get("Content-Type", "") + + assert "text/plain" in content_type + assert "messages_received_total" in body + assert "queue_depth" in body + assert re.search(r"^# HELP ", body, re.MULTILINE) + assert re.search(r"^# TYPE ", body, re.MULTILINE) + + +def test_metrics_endpoint_not_available_when_disabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + port = find_available_port() + monkeypatch.setenv("HACKLOG_METRICS_ENABLED", "false") + + assert start_metrics_server(port=port) is None + + with pytest.raises(urllib.error.URLError): + urllib.request.urlopen(f"http://127.0.0.1:{port}/metrics", timeout=1) diff --git a/tests/test_parse_syslog_msg.py b/tests/test_parse_syslog_msg.py new file mode 100644 index 0000000..046038c --- /dev/null +++ b/tests/test_parse_syslog_msg.py @@ -0,0 +1,102 @@ +"""WO-041 / WO-043: Parser accepts SyslogMsg entities instead of raw strings.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +_TESTS_DIR = Path(__file__).resolve().parent +_HACKLOG_DIR = _TESTS_DIR.parent / "hacklog" +for _path in (_HACKLOG_DIR, _TESTS_DIR.parent): + if str(_path) not in sys.path: + sys.path.insert(0, str(_path)) + +from entities import EventLog, SyslogMsg # noqa: E402 +from parse import Parser # noqa: E402 + +SUCCESS_LINE = ( + "<14>sshd[3070]: Accepted publickey for alice from 10.42.10.2 port 2005 ssh2" +) +FAILURE_LINE = ( + "<14>sshd[3070]: pam_unix(sshd:auth): authentication failure; login= " + "uid=0 euid=0 tty=ssh ruser= rhost=10.42.10.22 user=bob" +) + + +@pytest.fixture +def parser() -> Parser: + return Parser(validate_fields=True) + + +def test_parse_log_line_accepts_syslog_msg_entity(parser: Parser) -> None: + message = SyslogMsg(SUCCESS_LINE, "relay-host.internal", 514) + event = parser.parse_log_line(message) + assert isinstance(event, EventLog) + + +def test_parse_log_line_uses_syslog_msg_host_for_server(parser: Parser) -> None: + relay_host = "syslog-relay.example.com" + message = SyslogMsg(SUCCESS_LINE, relay_host, 514) + event = parser.parse_log_line(message) + assert event is not None + assert event.server == relay_host + assert relay_host not in SUCCESS_LINE + + +def test_parse_log_line_reads_payload_from_syslog_msg_data(parser: Parser) -> None: + message = SyslogMsg(SUCCESS_LINE, "prod-web-01", 514) + event = parser.parse_log_line(message) + assert event is not None + assert event.username == "alice" + assert event.ip_address == "10.42.10.2" + assert event.success is True + + +def test_parse_log_line_failure_pattern_uses_syslog_msg_host(parser: Parser) -> None: + relay_host = "edge-collector.internal" + message = SyslogMsg(FAILURE_LINE, relay_host, 514) + event = parser.parse_log_line(message) + assert event is not None + assert event.username == "bob" + assert event.ip_address == "10.42.10.22" + assert event.success is False + assert event.server == relay_host + + +def test_parse_log_line_returns_none_for_none_message(parser: Parser) -> None: + assert parser.parse_log_line(None) is None + + +def test_parse_log_line_distinguishes_host_from_data_prefix(parser: Parser) -> None: + """Host must not be taken from the first token of SyslogMsg.data.""" + data_with_hostlike_prefix = ( + "192.168.56.1 <14>sshd[3070]: Accepted publickey for carol " + "from 10.42.10.2 port 2005 ssh2" + ) + message = SyslogMsg(data_with_hostlike_prefix, "actual-relay", 514) + event = parser.parse_log_line(message) + assert event is not None + assert event.server == "actual-relay" + assert event.username == "carol" + + +def test_parse_log_line_signature_requires_syslog_msg() -> None: + """WO-043: public API accepts SyslogMsg, not raw strings.""" + import inspect + + signature = inspect.signature(Parser.parse_log_line) + message_param = signature.parameters["message"] + assert "SyslogMsg" in str(message_param.annotation) + + +def test_all_call_sites_use_syslog_msg_wrapper() -> None: + """WO-043: syslog_server passes SyslogMsg into parse_log_line.""" + import inspect + + from syslog_server import message_consumer + + source = inspect.getsource(message_consumer) + assert "parse_log_line(msg)" in source + assert "isinstance(msg, SyslogMsg)" in source diff --git a/tests/test_pickle_to_json_migration.py b/tests/test_pickle_to_json_migration.py new file mode 100644 index 0000000..cd3864c --- /dev/null +++ b/tests/test_pickle_to_json_migration.py @@ -0,0 +1,154 @@ +"""Integration tests for Alembic pickle-to-JSON migration.""" + +import json +import pickle +from datetime import datetime +from pathlib import Path + +import sqlalchemy as sa +from alembic import command +from alembic.config import Config +from sqlalchemy import ( + Column, + DateTime, + Integer, + LargeBinary, + MetaData, + String, + Table, + create_engine, +) + +PROFILE_FIXTURES = json.loads( + (Path(__file__).parent / "fixtures" / "profile_fixtures.json").read_text( + encoding="utf-8" + ) +) + +PROFILE_TABLES = { + "days": PROFILE_FIXTURES["days"], + "hours": PROFILE_FIXTURES["hours"], + "servers": PROFILE_FIXTURES["servers"], + "ipAddress": PROFILE_FIXTURES["ipAddress"], +} + +PROFILE_TYPE_BY_LEGACY_TABLE = { + "days": "days", + "hours": "hours", + "servers": "server", + "ipAddress": "ipAddress", +} + + +def _create_legacy_pickle_database(db_path: Path) -> dict[str, dict[str, dict]]: + engine = create_engine(f"sqlite:///{db_path}") + metadata = MetaData() + tables: dict[str, Table] = {} + + for table_name in PROFILE_TABLES: + tables[table_name] = Table( + table_name, + metadata, + Column("date", DateTime, primary_key=True), + Column("username", String, primary_key=True), + Column("profile", LargeBinary), + Column("totalCount", Integer), + ) + + metadata.create_all(engine) + stamp = datetime(2026, 1, 1, 0, 0, 0) + expected: dict[str, dict[str, dict]] = {} + + with engine.begin() as connection: + for table_name, profile in PROFILE_TABLES.items(): + username = f"{table_name}-user" + connection.execute( + sa.text(f""" + INSERT INTO {table_name} (date, username, profile, totalCount) + VALUES (:date, :username, :profile, :totalCount) + """), + { + "date": stamp, + "username": username, + "profile": pickle.dumps(profile), + "totalCount": sum(profile.values()), + }, + ) + expected[table_name] = {"username": username, "profile": profile} + + engine.dispose() + return expected + + +def _run_migration(db_path: Path, repo_root: Path) -> Path: + backup_path = db_path.with_suffix(db_path.suffix + ".pre-migration.bak") + alembic_cfg = Config(str(repo_root / "alembic.ini")) + alembic_cfg.set_main_option("script_location", str(repo_root / "migrations")) + alembic_cfg.set_main_option("sqlalchemy.url", f"sqlite:///{db_path}") + command.upgrade(alembic_cfg, "head") + assert backup_path.exists(), "pre-migration backup was not created" + return backup_path + + +def _load_migrated_profiles(db_path: Path) -> dict[str, dict]: + engine = create_engine(f"sqlite:///{db_path}") + migrated: dict[str, dict] = {} + + with engine.connect() as connection: + for table_name in PROFILE_TABLES: + profile_type = PROFILE_TYPE_BY_LEGACY_TABLE[table_name] + row = ( + connection.execute( + sa.text( + "SELECT username, profile FROM profiles " + "WHERE profileType = :profile_type" + ), + {"profile_type": profile_type}, + ) + .mappings() + .one() + ) + profile = row["profile"] + if isinstance(profile, str): + profile = json.loads(profile) + migrated[table_name] = {"username": row["username"], "profile": profile} + + engine.dispose() + return migrated + + +def test_migration_converts_pickle_profiles_to_json(tmp_path: Path) -> None: + repo_root = Path(__file__).resolve().parents[1] + db_path = tmp_path / "legacy.db" + expected = _create_legacy_pickle_database(db_path) + + _run_migration(db_path, repo_root) + migrated = _load_migrated_profiles(db_path) + + for table_name, fixture in expected.items(): + assert migrated[table_name]["username"] == fixture["username"] + assert migrated[table_name]["profile"] == fixture["profile"] + + +def test_migration_downgrade_is_best_effort_round_trip(tmp_path: Path) -> None: + repo_root = Path(__file__).resolve().parents[1] + db_path = tmp_path / "legacy-downgrade.db" + expected = _create_legacy_pickle_database(db_path) + + alembic_cfg = Config(str(repo_root / "alembic.ini")) + alembic_cfg.set_main_option("script_location", str(repo_root / "migrations")) + alembic_cfg.set_main_option("sqlalchemy.url", f"sqlite:///{db_path}") + + command.upgrade(alembic_cfg, "head") + command.downgrade(alembic_cfg, "base") + + engine = create_engine(f"sqlite:///{db_path}") + with engine.connect() as connection: + for table_name, fixture in expected.items(): + row = connection.execute( + sa.text(f"SELECT profile FROM {table_name} WHERE username = :username"), + {"username": fixture["username"]}, + ).one() + restored = pickle.loads(row[0], encoding="latin1") + assert restored == fixture["profile"] + engine.dispose() diff --git a/tests/test_profile_entity.py b/tests/test_profile_entity.py new file mode 100644 index 0000000..abd8985 --- /dev/null +++ b/tests/test_profile_entity.py @@ -0,0 +1,51 @@ +"""WO-044: Tests for consolidated Profile entity.""" + +from __future__ import annotations + +import sys +from datetime import datetime +from pathlib import Path + +import pytest + +_TESTS_DIR = Path(__file__).resolve().parent +_HACKLOG_DIR = _TESTS_DIR.parent / "hacklog" +for _path in (_HACKLOG_DIR, _TESTS_DIR.parent): + if str(_path) not in sys.path: + sys.path.insert(0, str(_path)) + +from entities import Profile, ProfileType # noqa: E402 +from services import UpdateService # noqa: E402 + + +@pytest.mark.parametrize( + "profile_type", + [ + ProfileType.DAYS, + ProfileType.HOURS, + ProfileType.SERVER, + ProfileType.IP_ADDRESS, + ], +) +def test_profile_entity_supports_all_legacy_profile_types( + profile_type: ProfileType, +) -> None: + profile = Profile(datetime(2026, 1, 1), "alice", profile_type, {"k": 1}, 1) + assert profile.profile_type == profile_type.value + + +def test_update_service_creates_unified_profile_rows() -> None: + from unittest.mock import MagicMock + + profile_repository = MagicMock() + profile_repository.get_profile.return_value = None + service = UpdateService(profile_repository=profile_repository) + + from entities import EventLog + + event = EventLog(datetime(2026, 1, 15, 10, 0), "bob", "10.42.10.2", True, "host") + service.update_and_return_day_freq_for_user(event) + + saved = profile_repository.save_profile.call_args[0][0] + assert isinstance(saved, Profile) + assert saved.profile_type == ProfileType.DAYS.value diff --git a/tests/test_read_csv.py b/tests/test_read_csv.py new file mode 100644 index 0000000..74a1809 --- /dev/null +++ b/tests/test_read_csv.py @@ -0,0 +1,180 @@ +"""Unit tests for hacklog.read_csv CSV replay utility.""" + +from __future__ import annotations + +import csv +import io +from datetime import datetime + +import pytest + +from hacklog import read_csv as read_csv_module +from hacklog.read_csv import ( + CSV_DATETIME_FORMAT_ENV, + ReadCSVFiles, + format_syslog_datetime, + get_csv_datetime_format, + parse_csv_datetime, + resolve_csv_input_path, +) + + +def test_parse_csv_datetime_valid() -> None: + parsed = parse_csv_datetime("2013-09-23 11:16:48") + assert parsed == datetime(2013, 9, 23, 11, 16, 48) + + +@pytest.mark.parametrize( + "raw_value", + [ + "", + " ", + "2013/09/23 11:16:48", + "2013-09-23T11:16:48", + "not-a-date", + "2013-13-45 99:99:99", + ], +) +def test_parse_csv_datetime_invalid_raises(raw_value: str) -> None: + with pytest.raises(ValueError, match="Invalid Date Time"): + parse_csv_datetime(raw_value) + + +def test_parse_csv_datetime_none_logs_and_raises( + monkeypatch: pytest.MonkeyPatch, +) -> None: + logged: list[str] = [] + + def capture_error(message: str) -> None: + logged.append(message) + + monkeypatch.setattr(read_csv_module.logger, "error", capture_error) + with pytest.raises(ValueError, match="value cannot be None"): + parse_csv_datetime(None) + assert any("value cannot be None" in message for message in logged) + + +def test_get_csv_datetime_format_reads_env( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv(CSV_DATETIME_FORMAT_ENV, "%Y/%m/%d %H:%M:%S") + assert get_csv_datetime_format() == "%Y/%m/%d %H:%M:%S" + + +def test_parse_csv_datetime_honors_env_format( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv(CSV_DATETIME_FORMAT_ENV, "%Y/%m/%d %H:%M:%S") + parsed = parse_csv_datetime("2013/09/23 11:16:48") + assert parsed == datetime(2013, 9, 23, 11, 16, 48) + + +def test_format_syslog_datetime_matches_parser_expectation() -> None: + event_time = datetime(2013, 9, 23, 11, 16, 48) + assert format_syslog_datetime(event_time) == "2013-09-23 11:16:48" + + +def test_log_messages_success_test_enabled(monkeypatch: pytest.MonkeyPatch) -> None: + logged: list[str] = [] + monkeypatch.setattr( + read_csv_module.logger, + "info", + lambda message: logged.append(message), + ) + reader = ReadCSVFiles(test_enabled=True) + + reader.log_messages( + { + "Date Time": "2013-09-23 11:16:48", + "User": "alice", + "IP": "10.42.10.2", + "Login_Status": "True", + "Server_Name": "ae1-app80-prd", + } + ) + + assert len(logged) == 1 + message = logged[0] + assert "Accepted publickey for alice" in message + assert "DATE_TIME 2013-09-23 11:16:48 HOST ae1-app80-prd" in message + + +def test_log_messages_failure_test_enabled(monkeypatch: pytest.MonkeyPatch) -> None: + logged: list[str] = [] + monkeypatch.setattr( + read_csv_module.logger, + "info", + lambda message: logged.append(message), + ) + reader = ReadCSVFiles(test_enabled=True) + + reader.log_messages( + { + "Date Time": "2013-10-05 14:30:30", + "User": "bob", + "IP": "10.42.28.46", + "Login_Status": "FALSE", + "Server_Name": "db-staging-02", + } + ) + + assert len(logged) == 1 + message = logged[0] + assert "authentication failure" in message + assert "user=bob" in message + assert "DATE_TIME 2013-10-05 14:30:30 HOST db-staging-02" in message + + +def test_log_messages_missing_required_field() -> None: + reader = ReadCSVFiles(test_enabled=True) + with pytest.raises(ValueError, match="missing required field"): + reader.log_messages( + { + "Date Time": "2013-09-23 11:16:48", + "User": "alice", + "IP": "10.42.10.2", + "Login_Status": "True", + } + ) + + +def test_resolve_csv_input_path_rejects_traversal(tmp_path) -> None: + safe_file = tmp_path / "sample.csv" + safe_file.write_text("header\n", encoding="utf-8") + + resolved = resolve_csv_input_path("sample.csv", base_dir=tmp_path) + assert resolved == safe_file.resolve() + + with pytest.raises(ValueError, match="CSV path must stay within"): + resolve_csv_input_path("../outside.csv", base_dir=tmp_path) + + +def test_read_line_generate_logs_skips_invalid_rows( + monkeypatch: pytest.MonkeyPatch, +) -> None: + info_messages: list[str] = [] + error_messages: list[str] = [] + + monkeypatch.setattr( + read_csv_module.logger, + "info", + lambda message: info_messages.append(message), + ) + monkeypatch.setattr( + read_csv_module.logger, + "error", + lambda message, *args: error_messages.append( + message % args if args else message + ), + ) + reader = ReadCSVFiles(test_enabled=True) + csv_buffer = io.StringIO( + "Date Time,User,IP,Login_Status,Server_Name\n" + "bad-date,alice,10.0.0.1,True,srv-01\n" + "2013-09-23 11:16:48,bob,10.0.0.2,True,srv-02\n" + ) + reader.read_line_generate_logs(csv.reader(csv_buffer)) + + assert len(info_messages) == 1 + assert "Accepted publickey for bob" in info_messages[0] + assert any("Skipping CSV row 2" in message for message in error_messages) diff --git a/tests/test_repositories.py b/tests/test_repositories.py new file mode 100644 index 0000000..23a81d6 --- /dev/null +++ b/tests/test_repositories.py @@ -0,0 +1,142 @@ +"""Tests for repository pattern data access layer.""" + +import sys +from datetime import datetime +from pathlib import Path + +import pytest +from sqlalchemy import create_engine, select +from sqlalchemy.orm import sessionmaker + +_TESTS_DIR = Path(__file__).resolve().parent +_HACKLOG_DIR = _TESTS_DIR.parent / "hacklog" +for _path in (_HACKLOG_DIR, _TESTS_DIR.parent): + if str(_path) not in sys.path: + sys.path.insert(0, str(_path)) + +from entities import ( # noqa: E402 + EventLog, + Profile, + ProfileType, + User, + create_tables, +) +from repositories import ( # noqa: E402 + AuditRepository, + ProfileRepository, + UserRepository, +) + + +@pytest.fixture +def session_factory(tmp_path: Path): + engine = create_engine(f"sqlite:///{tmp_path / 'repos.db'}") + create_tables(engine) + factory = sessionmaker( + bind=engine, autoflush=True, autocommit=False, expire_on_commit=False + ) + yield factory + engine.dispose() + + +@pytest.fixture +def profile_repository(session_factory) -> ProfileRepository: + return ProfileRepository(session_factory) + + +@pytest.fixture +def user_repository(session_factory) -> UserRepository: + return UserRepository(session_factory) + + +@pytest.fixture +def audit_repository(session_factory) -> AuditRepository: + return AuditRepository(session_factory) + + +@pytest.mark.parametrize( + ("profile_type", "username"), + [ + (ProfileType.DAYS, "days-user"), + (ProfileType.HOURS, "hours-user"), + (ProfileType.SERVER, "servers-user"), + (ProfileType.IP_ADDRESS, "ip-user"), + ], +) +def test_profile_repository_crud( + profile_type: ProfileType, username: str, profile_repository +) -> None: + profile = Profile(datetime(2026, 1, 1), username, profile_type, {"Mon": 1}, 1) + profile_repository.save_profile(profile) + loaded = profile_repository.get_profile(profile_type, username) + assert loaded is not None + assert loaded.username == username + loaded.profile = {"Mon": 2, "Tue": 1} + loaded.total_count = 3 + profile_repository.update_profile(loaded) + reloaded = profile_repository.get_profile(profile_type, username) + assert reloaded is not None + assert reloaded.profile["Mon"] == 2 + + +def test_user_repository_crud(user_repository) -> None: + user = User("repo-user", datetime(2026, 2, 1), 10) + user_repository.save(user) + loaded = user_repository.get_by_username("repo-user") + assert loaded is not None + user_repository.update_score(loaded, 42) + user_repository.update_scare_count(loaded) + user_repository.reset_scare_count(loaded) + final = user_repository.get_by_username("repo-user") + assert final is not None + assert final.score == 42 + assert final.scare_count == 0 + + +def test_audit_repository_append_only(audit_repository, session_factory) -> None: + event = EventLog(datetime(2026, 3, 1), "audit-user", "10.0.0.1", True, "host") + audit_repository.save_event(event) + with session_factory() as session: + count = session.execute(select(EventLog)).scalars().all() + assert len(count) == 1 + + +def test_transaction_rolls_back_on_failure(profile_repository, session_factory) -> None: + profile = Profile( + datetime(2026, 4, 1), "rollback-user", ProfileType.DAYS, {"Mon": 1}, 1 + ) + profile_repository.save_profile(profile) + + class BrokenProfileRepository(ProfileRepository): + def save_profile(self, profile: Profile) -> None: + with self.transaction() as session: + session.add( + Profile( + datetime(2026, 4, 1), + "rollback-user", + ProfileType.HOURS, + {"early": 1}, + 1, + ) + ) + raise RuntimeError("forced failure") + + broken = BrokenProfileRepository(session_factory) + with pytest.raises(RuntimeError): + broken.save_profile( + Profile( + datetime(2026, 4, 1), + "rollback-user", + ProfileType.HOURS, + {"early": 1}, + 1, + ) + ) + + assert profile_repository.get_profile(ProfileType.HOURS, "rollback-user") is None + assert profile_repository.get_profile(ProfileType.DAYS, "rollback-user") is not None + + +def test_repositories_use_injected_session_factory(session_factory) -> None: + repo = ProfileRepository(session_factory) + assert repo.session_factory is session_factory diff --git a/tests/test_retention.py b/tests/test_retention.py new file mode 100644 index 0000000..1eb4bf4 --- /dev/null +++ b/tests/test_retention.py @@ -0,0 +1,476 @@ +"""Tests for DataRetentionService: purge logic, audit records, and scheduling.""" + +import asyncio +import sys +from datetime import datetime, timedelta +from pathlib import Path + +import pytest +from sqlalchemy import create_engine, select +from sqlalchemy.orm import sessionmaker + +_TESTS_DIR = Path(__file__).resolve().parent +_HACKLOG_DIR = _TESTS_DIR.parent / "hacklog" +for _path in (_HACKLOG_DIR, _TESTS_DIR.parent): + if str(_path) not in sys.path: + sys.path.insert(0, str(_path)) + +from entities import ( # noqa: E402 + AuditRecord, + EventLog, + Profile, + ProfileType, + User, + create_tables, +) +from repositories import AuditRepository # noqa: E402 +from retention import DataRetentionService # noqa: E402 + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _ago(days: int) -> datetime: + """Return a naive UTC datetime that is `days` days in the past.""" + return datetime.utcnow() - timedelta(days=days) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def session_factory(tmp_path: Path): + engine = create_engine(f"sqlite:///{tmp_path / 'retention_test.db'}") + create_tables(engine) + factory = sessionmaker( + bind=engine, autoflush=True, autocommit=False, expire_on_commit=False + ) + yield factory + engine.dispose() + + +@pytest.fixture +def audit_repository(session_factory) -> AuditRepository: + return AuditRepository(session_factory) + + +@pytest.fixture +def retention_service(session_factory, audit_repository) -> DataRetentionService: + return DataRetentionService( + session_factory, + audit_repository, + event_retention_days=30, + profile_inactivity_days=90, + batch_size=10, + purge_schedule_hour=2, + ) + + +def _add_event(session_factory, username: str, days_ago: int) -> None: + date = _ago(days_ago) + with session_factory() as session: + session.add(EventLog(date, username, "10.0.0.1", True, "host")) + session.commit() + + +def _add_user(session_factory, username: str, days_ago: int) -> None: + date = _ago(days_ago) + with session_factory() as session: + user = User(username, date, 0) + session.add(user) + session.commit() + + +def _count(session_factory, entity_cls) -> int: + with session_factory() as session: + return len(session.execute(select(entity_cls)).scalars().all()) + + +def _usernames(session_factory, entity_cls) -> set[str]: + with session_factory() as session: + return {r.username for r in session.execute(select(entity_cls)).scalars().all()} + + +def _add_profile( + session_factory, profile_type: ProfileType, username: str, days_ago: int +) -> None: + date = _ago(days_ago) + with session_factory() as session: + session.add(Profile(date, username, profile_type, {"Mon": 1}, 1)) + session.commit() + + +def _count_profiles(session_factory, profile_type: ProfileType | None = None) -> int: + with session_factory() as session: + query = select(Profile) + if profile_type is not None: + query = query.where(Profile.profile_type == profile_type.value) + return len(session.execute(query).scalars().all()) + + +def _profile_usernames( + session_factory, profile_type: ProfileType | None = None +) -> set[str]: + with session_factory() as session: + query = select(Profile) + if profile_type is not None: + query = query.where(Profile.profile_type == profile_type.value) + return {r.username for r in session.execute(query).scalars().all()} + + +# --------------------------------------------------------------------------- +# Event log purge tests +# --------------------------------------------------------------------------- + + +def test_event_logs_beyond_retention_are_deleted( + session_factory, retention_service +) -> None: + _add_event(session_factory, "old-user", 40) # 40 days old — beyond 30-day retention + _add_event(session_factory, "new-user", 10) # 10 days old — within retention + + deleted = retention_service.purge_event_logs() + + assert deleted == 1 + assert _count(session_factory, EventLog) == 1 + assert _usernames(session_factory, EventLog) == {"new-user"} + + +def test_event_logs_within_retention_are_preserved( + session_factory, retention_service +) -> None: + _add_event(session_factory, "safe-user", 1) + + deleted = retention_service.purge_event_logs() + + assert deleted == 0 + assert _count(session_factory, EventLog) == 1 + + +def test_purge_event_logs_boundary(session_factory, retention_service) -> None: + """Record exactly at the boundary (30 days old) is preserved (cutoff is strict <).""" + _add_event(session_factory, "boundary-user", 29) # just inside retention + _add_event(session_factory, "beyond-user", 31) # just beyond retention + + deleted = retention_service.purge_event_logs() + + assert deleted == 1 + assert _usernames(session_factory, EventLog) == {"boundary-user"} + + +def test_purge_event_logs_is_idempotent(session_factory, retention_service) -> None: + _add_event(session_factory, "idem-user", 50) + + first = retention_service.purge_event_logs() + second = retention_service.purge_event_logs() + + assert first == 1 + assert second == 0 + + +def test_purge_event_logs_batch_processing(session_factory, audit_repository) -> None: + """Verify batch_size=3 correctly handles more records than one batch.""" + service = DataRetentionService( + session_factory, + audit_repository, + event_retention_days=30, + batch_size=3, + ) + # Insert 7 old records + for i in range(7): + _add_event(session_factory, f"batch-user-{i}", 40 + i) + # Insert 2 recent records + _add_event(session_factory, "keep-1", 5) + _add_event(session_factory, "keep-2", 10) + + deleted = service.purge_event_logs() + + assert deleted == 7 + assert _count(session_factory, EventLog) == 2 + + +# --------------------------------------------------------------------------- +# Profile purge tests +# --------------------------------------------------------------------------- + + +def test_inactive_profiles_are_purged(session_factory, retention_service) -> None: + """All records for an inactive user are removed across every profile table.""" + username = "stale-user" + _add_user(session_factory, username, 200) + _add_event(session_factory, username, 200) + _add_profile(session_factory, ProfileType.DAYS, username, 200) + _add_profile(session_factory, ProfileType.HOURS, username, 200) + _add_profile(session_factory, ProfileType.SERVER, username, 200) + _add_profile(session_factory, ProfileType.IP_ADDRESS, username, 200) + + purged = retention_service.purge_inactive_profiles() + + assert purged == 1 + assert _count(session_factory, User) == 0 + assert _count_profiles(session_factory) == 0 + + +def test_active_profiles_are_preserved(session_factory, retention_service) -> None: + username = "active-user" + _add_user(session_factory, username, 5) + _add_event(session_factory, username, 5) + _add_profile(session_factory, ProfileType.DAYS, username, 5) + + purged = retention_service.purge_inactive_profiles() + + assert purged == 0 + assert _count(session_factory, User) == 1 + assert _count_profiles(session_factory, ProfileType.DAYS) == 1 + + +def test_profile_inactivity_uses_most_recent_activity( + session_factory, retention_service +) -> None: + """User with old profile but recent event log is NOT purged.""" + username = "recently-active" + _add_user(session_factory, username, 200) + _add_profile(session_factory, ProfileType.DAYS, username, 200) # old profile record + _add_event(session_factory, username, 10) # recent EventLog keeps them active + + purged = retention_service.purge_inactive_profiles() + + assert purged == 0 + assert _count(session_factory, User) == 1 + + +def test_purge_inactive_profiles_is_idempotent( + session_factory, retention_service +) -> None: + _add_user(session_factory, "idem-profile", 200) + _add_event(session_factory, "idem-profile", 200) + + first = retention_service.purge_inactive_profiles() + second = retention_service.purge_inactive_profiles() + + assert first == 1 + assert second == 0 + + +# --------------------------------------------------------------------------- +# Audit record tests +# --------------------------------------------------------------------------- + + +def test_purge_event_logs_creates_audit_record( + session_factory, retention_service +) -> None: + _add_event(session_factory, "audit-ev-user", 40) + + retention_service.purge_event_logs() + + with session_factory() as session: + records = ( + session.execute( + select(AuditRecord).where(AuditRecord.action == "event_logs_purged") + ) + .scalars() + .all() + ) + + assert len(records) == 1 + rec = records[0] + assert rec.actor == "system" + assert rec.resource == "database" + assert rec.details["records_deleted"] == 1 + assert rec.details["retention_days"] == 30 + + +def test_purge_inactive_profiles_creates_audit_record( + session_factory, retention_service +) -> None: + _add_user(session_factory, "audit-prof", 200) + _add_event(session_factory, "audit-prof", 200) + + retention_service.purge_inactive_profiles() + + with session_factory() as session: + records = ( + session.execute( + select(AuditRecord).where( + AuditRecord.action == "inactive_profiles_purged" + ) + ) + .scalars() + .all() + ) + + assert len(records) == 1 + rec = records[0] + assert rec.details["users_purged"] == 1 + assert rec.details["inactivity_days"] == 90 + + +def test_purge_without_audit_repository_does_not_raise(session_factory) -> None: + service = DataRetentionService( + session_factory, + audit_repository=None, + event_retention_days=30, + ) + _add_event(session_factory, "no-audit-user", 40) + + deleted = service.purge_event_logs() + assert deleted == 1 + + +# --------------------------------------------------------------------------- +# System integration test: mixed timestamps +# --------------------------------------------------------------------------- + + +def test_run_purge_full_pipeline(session_factory, retention_service) -> None: + """End-to-end: create records spanning the retention boundary, run purge.""" + # 3 old event logs, 2 recent + for i in range(3): + _add_event(session_factory, f"old-ev-{i}", 35 + i) + for i in range(2): + _add_event(session_factory, f"new-ev-{i}", i + 1) + + # 1 inactive user (with all profile types), 1 active user + _add_user(session_factory, "stale", 200) + _add_event(session_factory, "stale", 200) + for profile_type in ProfileType: + _add_profile(session_factory, profile_type, "stale", 200) + + _add_user(session_factory, "fresh", 5) + _add_event(session_factory, "fresh", 5) + _add_profile(session_factory, ProfileType.DAYS, "fresh", 5) + + summary = retention_service.run_purge() + + assert summary["event_logs_deleted"] == 4 + assert summary["users_purged"] == 1 + assert "elapsed_seconds" in summary + assert "run_at" in summary + + # Active user's profile preserved + assert _count_profiles(session_factory, ProfileType.DAYS) == 1 + assert _profile_usernames(session_factory, ProfileType.DAYS) == {"fresh"} + + # Old event logs gone; recent remain (plus the "fresh" user's event log) + remaining = _usernames(session_factory, EventLog) + assert "new-ev-0" in remaining + assert "new-ev-1" in remaining + for i in range(3): + assert f"old-ev-{i}" not in remaining + + # Audit records created + with session_factory() as session: + ev_audit = ( + session.execute( + select(AuditRecord).where(AuditRecord.action == "event_logs_purged") + ) + .scalars() + .all() + ) + prof_audit = ( + session.execute( + select(AuditRecord).where( + AuditRecord.action == "inactive_profiles_purged" + ) + ) + .scalars() + .all() + ) + assert len(ev_audit) == 1 + assert len(prof_audit) == 1 + + +# --------------------------------------------------------------------------- +# Config tests +# --------------------------------------------------------------------------- + + +def test_retention_config_defaults() -> None: + from config import RetentionConfig + + cfg = RetentionConfig() + assert cfg.event_retention_days == 365 + assert cfg.profile_inactivity_days == 180 + assert cfg.purge_schedule_hour == 2 + assert cfg.purge_batch_size == 1000 + + +def test_retention_config_env_override(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("HACKLOG_EVENT_RETENTION_DAYS", "90") + monkeypatch.setenv("HACKLOG_PROFILE_INACTIVITY_DAYS", "60") + + from config import _RetentionSettings + + settings = _RetentionSettings() + assert settings.event_retention_days == 90 + assert settings.profile_inactivity_days == 60 + + +def test_config_manager_has_retention(monkeypatch: pytest.MonkeyPatch) -> None: + for key in ( + "HACKLOG_SMTP_USER", + "HACKLOG_SMTP_PASSWORD", + "HACKLOG_SMTP_SENDER", + "HACKLOG_ALERT_RECIPIENT", + ): + monkeypatch.delenv(key, raising=False) + monkeypatch.setenv("HACKLOG_SMTP_USER", "u@example.com") + monkeypatch.setenv("HACKLOG_SMTP_PASSWORD", "pw") + monkeypatch.setenv("HACKLOG_SMTP_SENDER", "u@example.com") + monkeypatch.setenv("HACKLOG_ALERT_RECIPIENT", "r@example.com") + monkeypatch.setenv("HACKLOG_EVENT_RETENTION_DAYS", "180") + + from config import load_config + + cfg = load_config() + + assert cfg.retention.event_retention_days == 180 + assert cfg.retention.profile_inactivity_days == 180 # default + + +# --------------------------------------------------------------------------- +# Async scheduler smoke test +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_schedule_daily_purge_sleeps_until_next_run( + session_factory, audit_repository +) -> None: + """Smoke test: scheduler calls asyncio.sleep and run_purge.""" + service = DataRetentionService( + session_factory, + audit_repository, + event_retention_days=30, + purge_schedule_hour=2, + ) + + sleep_calls: list[float] = [] + run_calls: list[None] = [] + + async def fake_sleep(seconds: float) -> None: + sleep_calls.append(seconds) + if len(sleep_calls) >= 2: + raise asyncio.CancelledError + + async def fake_to_thread(fn, *args, **kwargs): + run_calls.append(None) + + import unittest.mock as mock + + import retention as ret_module + + with mock.patch.object(ret_module.asyncio, "sleep", fake_sleep): + with mock.patch.object(ret_module.asyncio, "to_thread", fake_to_thread): + with pytest.raises(asyncio.CancelledError): + await service.schedule_daily_purge() + + # First sleep should be ≥0 seconds (waiting until next 02:00 UTC) + assert len(sleep_calls) >= 1 + assert sleep_calls[0] >= 0 + # run_purge was invoked at least once + assert len(run_calls) >= 1 diff --git a/tests/test_scoring_engine.py b/tests/test_scoring_engine.py new file mode 100644 index 0000000..a949ee3 --- /dev/null +++ b/tests/test_scoring_engine.py @@ -0,0 +1,126 @@ +"""Unit tests for ScoringEngine dependency injection.""" + +import sys +from datetime import datetime +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +_TESTS_DIR = Path(__file__).resolve().parent +_HACKLOG_DIR = _TESTS_DIR.parent / "hacklog" +for _path in (_HACKLOG_DIR, _TESTS_DIR.parent): + if str(_path) not in sys.path: + sys.path.insert(0, str(_path)) + +from entities import EventLog, Profile, ProfileType, Threshold, User # noqa: E402 +from scoring import ScoringEngine # noqa: E402 +from services import UpdateService # noqa: E402 + + +@pytest.fixture +def event_log() -> EventLog: + return EventLog( + datetime(2026, 1, 15, 10, 0, 0), "nrhine", "10.42.10.2", False, "prod-host" + ) + + +@pytest.fixture +def mock_services(): + update_service = MagicMock() + alert_service = MagicMock() + user = User("nrhine", datetime(2026, 1, 15, 10, 0, 0), 0) + update_service.fetch_user.return_value = user + update_service.update_and_return_hour_freq_for_user.return_value = 0.5 + update_service.update_and_return_day_freq_for_user.return_value = 0.5 + update_service.update_and_return_server_freq_for_user.return_value = 0.5 + update_service.update_and_return_ip_freq_for_user.return_value = 0.5 + return update_service, alert_service, user + + +def test_scoring_engine_instantiates_with_mock_services(mock_services) -> None: + update_service, alert_service, _user = mock_services + engine = ScoringEngine(update_service, alert_service) + assert engine is not None + + +def test_process_event_log_audits_and_updates_score(mock_services, event_log) -> None: + update_service, alert_service, user = mock_services + engine = ScoringEngine(update_service, alert_service) + engine.process_event_log(event_log) + update_service.audit_event_log.assert_called_once_with(event_log) + update_service.fetch_user.assert_called_once_with(event_log) + update_service.update_user_score.assert_called_once() + alert_service.send_email_alert.assert_not_called() + + +def test_critical_score_triggers_alert(mock_services, event_log) -> None: + update_service, alert_service, user = mock_services + engine = ScoringEngine(update_service, alert_service) + engine.calculate_new_score = MagicMock(return_value=(Threshold.CRITICAL + 1, {})) # type: ignore[method-assign] + engine.process_event_log(event_log) + alert_service.send_email_alert.assert_called_once_with(user, event_log) + + +def test_calculate_subscore_bounds_high_frequency() -> None: + assert ScoringEngine.calculate_subscore(1.0) == 0.0 + + +def test_calculate_subscore_returns_normalized_value_for_mid_frequency() -> None: + subscore = ScoringEngine.calculate_subscore(0.5) + assert 0.0 < subscore <= 1.0 + assert subscore == pytest.approx(0.1) + + +def test_calculate_subscore_caps_at_one_for_rare_events() -> None: + subscore = ScoringEngine.calculate_subscore(0.0001) + assert subscore == 1.0 + + +@pytest.mark.parametrize( + "freq", + [1.0, 0.5, 0.25, 0.01, 0.0001], +) +def test_calculate_subscore_stays_within_unit_interval(freq: float) -> None: + subscore = ScoringEngine.calculate_subscore(freq) + assert 0.0 <= subscore <= 1.0 + + +def test_update_user_score_persists_via_repository() -> None: + user_repository = MagicMock() + service = UpdateService(user_repository=user_repository) + user = User("nrhine", datetime(2026, 1, 15, 10, 0, 0), 0) + + service.update_user_score(user, 72) + + user_repository.update_score.assert_called_once_with(user, 72) + + +def test_update_and_return_freq_for_profile_uses_float_division() -> None: + profile_repository = MagicMock() + service = UpdateService(profile_repository=profile_repository) + profile = Profile( + datetime(2026, 1, 15, 10, 0, 0), "nrhine", ProfileType.DAYS, {"Mon": 2}, 7 + ) + + freq = service.update_and_return_freq_for_profile(profile, "Mon") + + assert freq == pytest.approx(3 / 8) + profile_repository.update_profile.assert_called_once() + + +def test_calculate_success_score_failure_adds_weight(event_log) -> None: + event_log.success = False + update_service = MagicMock() + alert_service = MagicMock() + engine = ScoringEngine(update_service, alert_service) + score = engine.calculate_success_score(event_log.success) + assert score > 0 + + +def test_calculate_success_score_success_is_zero(event_log) -> None: + event_log.success = True + update_service = MagicMock() + alert_service = MagicMock() + engine = ScoringEngine(update_service, alert_service) + assert engine.calculate_success_score(event_log.success) == 0 diff --git a/tests/test_scoring_pipeline.py b/tests/test_scoring_pipeline.py new file mode 100644 index 0000000..0575627 --- /dev/null +++ b/tests/test_scoring_pipeline.py @@ -0,0 +1,42 @@ +"""Integration test: syslog parse → score pipeline with injected dependencies.""" + +import sys +from datetime import datetime +from pathlib import Path +from unittest.mock import MagicMock + +_TESTS_DIR = Path(__file__).resolve().parent +_HACKLOG_DIR = _TESTS_DIR.parent / "hacklog" +for _path in (_HACKLOG_DIR, _TESTS_DIR.parent): + if str(_path) not in sys.path: + sys.path.insert(0, str(_path)) + +from entities import EventLog, SyslogMsg, User # noqa: E402 +from parse import Parser # noqa: E402 +from scoring import ScoringEngine # noqa: E402 + + +def test_pipeline_parse_to_score_with_injected_mocks() -> None: + syslog_line = ( + "<14>sshd[3070]: Accepted publickey for nrhine from 10.42.10.2 port 2005 ssh2" + ) + parser = Parser() + syslog_msg = SyslogMsg(syslog_line, "127.0.0.1", 514) + event_log = parser.parse_log_line(syslog_msg) + assert isinstance(event_log, EventLog) + + update_service = MagicMock() + alert_service = MagicMock() + user = User("nrhine", datetime.now(), 0) + update_service.fetch_user.return_value = user + update_service.update_and_return_hour_freq_for_user.return_value = 0.25 + update_service.update_and_return_day_freq_for_user.return_value = 0.25 + update_service.update_and_return_server_freq_for_user.return_value = 0.25 + update_service.update_and_return_ip_freq_for_user.return_value = 0.25 + + engine = ScoringEngine(update_service, alert_service) + engine.process_event_log(event_log) + + update_service.audit_event_log.assert_called_once_with(event_log) + update_service.update_user_score.assert_called_once() + alert_service.send_email_alert.assert_not_called() diff --git a/tests/test_security.py b/tests/test_security.py new file mode 100644 index 0000000..e6fcac8 --- /dev/null +++ b/tests/test_security.py @@ -0,0 +1,190 @@ +"""Unit and integration tests for hacklog.security.""" + +import socket +import threading +import time + +import pytest + +from hacklog.metrics import messages_dropped_total, messages_received_total +from hacklog.security import ( + IpAllowlist, + MessageValidator, + RateLimiter, + TokenBucket, + build_message_validator, + parse_allowed_cidrs, +) + + +@pytest.fixture +def metered_validator() -> MessageValidator: + return MessageValidator( + allowlist=IpAllowlist(["10.0.0.0/8"]), + max_message_size=2048, + rate_limiter=RateLimiter(rate_per_second=100, burst_capacity=100), + meter_and_log=True, + ) + + +def test_rejected_messages_increment_prometheus_counter( + metered_validator: MessageValidator, +) -> None: + before = messages_dropped_total.labels( + reason="ip_rejected" + )._value.get() # noqa: SLF001 + metered_validator.validate("203.0.113.5", b"drop-me") + after = messages_dropped_total.labels( + reason="ip_rejected" + )._value.get() # noqa: SLF001 + assert after - before == 1.0 + + +def test_accepted_messages_increment_received_counter() -> None: + before = messages_received_total._value.get() # noqa: SLF001 + validator = MessageValidator( + allowlist=IpAllowlist([]), + max_message_size=2048, + rate_limiter=RateLimiter(rate_per_second=100, burst_capacity=100), + meter_and_log=True, + ) + validator.validate("10.0.0.5", b"accepted") + after = messages_received_total._value.get() # noqa: SLF001 + assert after - before == 1.0 + + +def test_parse_allowed_cidrs_splits_comma_separated_values() -> None: + assert parse_allowed_cidrs("10.0.0.0/8, 192.168.0.0/16") == [ + "10.0.0.0/8", + "192.168.0.0/16", + ] + + +def test_build_message_validator_reads_env_allowed_cidrs( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("HACKLOG_ALLOWED_CIDRS", "192.168.0.0/16") + validator = build_message_validator(meter_and_log=False) + assert validator.validate("192.168.1.10", b"x").accepted is True + assert validator.validate("10.1.1.1", b"x").accepted is False + + +def test_empty_allowlist_accepts_all_ips() -> None: + allowlist = IpAllowlist([]) + assert allowlist.is_allowed("10.42.10.2") is True + assert allowlist.is_allowed("203.0.113.5") is True + + +def test_allowlisted_ip_is_accepted() -> None: + validator = MessageValidator( + allowlist=IpAllowlist(["10.0.0.0/8"]), + max_message_size=2048, + rate_limiter=RateLimiter(rate_per_second=100, burst_capacity=100), + meter_and_log=False, + ) + result = validator.validate("10.42.10.2", b"ok") + assert result.accepted is True + + +def test_non_allowlisted_ip_is_rejected() -> None: + validator = MessageValidator( + allowlist=IpAllowlist(["10.0.0.0/8"]), + max_message_size=2048, + rate_limiter=RateLimiter(rate_per_second=100, burst_capacity=100), + meter_and_log=False, + ) + result = validator.validate("203.0.113.5", b"bad") + assert result.accepted is False + assert result.reason == "ip_rejected" + + +def test_cidr_range_matching() -> None: + allowlist = IpAllowlist(["10.0.0.0/8"]) + assert allowlist.is_allowed("10.42.10.2") is True + assert allowlist.is_allowed("11.0.0.1") is False + + +def test_oversized_message_is_rejected() -> None: + validator = MessageValidator( + allowlist=IpAllowlist([]), + max_message_size=32, + rate_limiter=RateLimiter(rate_per_second=100, burst_capacity=100), + meter_and_log=False, + ) + result = validator.validate("10.0.0.1", b"x" * 33) + assert result.accepted is False + assert result.reason == "oversized" + + +def test_rate_limited_source_is_rejected_after_burst() -> None: + validator = MessageValidator( + allowlist=IpAllowlist([]), + max_message_size=2048, + rate_limiter=RateLimiter(rate_per_second=5, burst_capacity=2), + meter_and_log=False, + ) + assert validator.validate("10.0.0.9", b"a").accepted is True + assert validator.validate("10.0.0.9", b"b").accepted is True + result = validator.validate("10.0.0.9", b"c") + assert result.accepted is False + assert result.reason == "rate_limited" + + +def test_token_bucket_refills_over_time() -> None: + bucket = TokenBucket(rate_per_second=10, burst_capacity=1) + assert bucket.consume() is True + assert bucket.consume() is False + time.sleep(0.2) + assert bucket.consume() is True + + +def test_rate_limiter_isolates_sources() -> None: + limiter = RateLimiter(rate_per_second=1, burst_capacity=1) + assert limiter.allow("10.0.0.1") is True + assert limiter.allow("10.0.0.1") is False + assert limiter.allow("10.0.0.2") is True + + +def test_udp_integration_accepts_and_rejects_datagrams() -> None: + validator = MessageValidator( + allowlist=IpAllowlist(["127.0.0.0/8"]), + max_message_size=2048, + rate_limiter=RateLimiter(rate_per_second=100, burst_capacity=100), + meter_and_log=False, + ) + accepted: list[tuple[str, bytes]] = [] + rejected: list[tuple[str, str]] = [] + stop_event = threading.Event() + + def serve() -> None: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.bind(("127.0.0.1", 0)) + sock.settimeout(0.2) + port = sock.getsockname()[1] + serve.port = port # type: ignore[attr-defined] + while not stop_event.is_set(): + try: + payload, (host, _port) = sock.recvfrom(4096) + except socket.timeout: + continue + result = validator.validate(host, payload) + if result.accepted: + accepted.append((host, payload)) + else: + rejected.append((host, result.reason or "unknown")) + + thread = threading.Thread(target=serve, daemon=True) + thread.start() + while not hasattr(serve, "port"): + time.sleep(0.01) + port = serve.port # type: ignore[attr-defined] + + client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + client.sendto(b"allowed", ("127.0.0.1", port)) + client.sendto(b"x" * 3000, ("127.0.0.1", port)) + time.sleep(0.3) + stop_event.set() + thread.join(timeout=1) + + assert any(payload == b"allowed" for _host, payload in accepted) + assert any(reason == "oversized" for _host, reason in rejected) diff --git a/tests/test_syslog_server.py b/tests/test_syslog_server.py new file mode 100644 index 0000000..9a8fec9 --- /dev/null +++ b/tests/test_syslog_server.py @@ -0,0 +1,363 @@ +"""Tests for asyncio syslog_server module.""" + +import asyncio +import signal +import socket +from collections.abc import Callable +from unittest.mock import MagicMock + +import pytest + +from hacklog.entities import SyslogMsg +from hacklog.metrics import messages_dropped_total +from hacklog.security import IpAllowlist, MessageValidator, RateLimiter +from hacklog.syslog_server import ( + SyslogProtocol, + message_consumer, + run_async_syslog_server, +) + + +def _validator( + *, + cidrs: list[str] | None = None, + max_size: int = 2048, + rate: float = 100, + burst: int = 100, +) -> MessageValidator: + return MessageValidator( + allowlist=IpAllowlist(cidrs or []), + max_message_size=max_size, + rate_limiter=RateLimiter(rate_per_second=rate, burst_capacity=burst), + meter_and_log=False, + ) + + +@pytest.mark.asyncio +async def test_datagram_received_enqueues_valid_message() -> None: + queue: asyncio.Queue = asyncio.Queue(maxsize=10) + protocol = SyslogProtocol(queue, _validator(), accepting=lambda: True) + protocol.datagram_received(b"hello syslog", ("127.0.0.1", 1234)) + + msg = await queue.get() + assert isinstance(msg, SyslogMsg) + assert msg.data == "hello syslog" + assert msg.host == "127.0.0.1" + assert msg.port == 1234 + + +@pytest.mark.asyncio +async def test_datagram_received_rejects_non_allowlisted_ip() -> None: + queue: asyncio.Queue = asyncio.Queue(maxsize=10) + protocol = SyslogProtocol( + queue, + _validator(cidrs=["10.0.0.0/8"]), + accepting=lambda: True, + ) + protocol.datagram_received(b"blocked", ("203.0.113.1", 9000)) + assert queue.empty() + + +@pytest.mark.asyncio +async def test_datagram_received_rejects_oversized_message() -> None: + queue: asyncio.Queue = asyncio.Queue(maxsize=10) + protocol = SyslogProtocol(queue, _validator(max_size=16), accepting=lambda: True) + protocol.datagram_received(b"x" * 32, ("127.0.0.1", 9000)) + assert queue.empty() + + +@pytest.mark.asyncio +async def test_datagram_received_rate_limits_excessive_sources() -> None: + queue: asyncio.Queue = asyncio.Queue(maxsize=10) + protocol = SyslogProtocol( + queue, + _validator(rate=1, burst=1), + accepting=lambda: True, + ) + protocol.datagram_received(b"one", ("10.0.0.5", 9000)) + protocol.datagram_received(b"two", ("10.0.0.5", 9000)) + assert queue.qsize() == 1 + + +@pytest.mark.asyncio +async def test_datagram_received_drops_when_queue_full() -> None: + queue: asyncio.Queue = asyncio.Queue(maxsize=1) + queue.put_nowait(SyslogMsg("existing", "127.0.0.1", 1)) + protocol = SyslogProtocol(queue, _validator(), accepting=lambda: True) + + before = messages_dropped_total.labels( + reason="queue_full" + )._value.get() # noqa: SLF001 + protocol.datagram_received(b"overflow", ("127.0.0.1", 9000)) + after = messages_dropped_total.labels( + reason="queue_full" + )._value.get() # noqa: SLF001 + assert after - before == 1.0 + assert queue.qsize() == 1 + + +@pytest.mark.asyncio +async def test_message_consumer_processes_enqueued_messages() -> None: + queue: asyncio.Queue = asyncio.Queue(maxsize=10) + parser = MagicMock() + parser.parse_log_line.return_value = object() + processed: list[object] = [] + + queue.put_nowait(SyslogMsg("payload", "127.0.0.1", 42)) + running = True + + async def consume_once() -> None: + nonlocal running + await message_consumer( + queue, + parser, + processed.append, + running=lambda: running, + ) + + task = asyncio.create_task(consume_once()) + await asyncio.sleep(0.1) + running = False + await task + + assert len(processed) == 1 + parser.parse_log_line.assert_called_once() + + +@pytest.mark.asyncio +async def test_udp_integration_receives_datagram_via_asyncio_server() -> None: + queue: asyncio.Queue = asyncio.Queue(maxsize=10) + loop = asyncio.get_running_loop() + ready = asyncio.Event() + + class _TestProtocol(SyslogProtocol): + def connection_made(self, transport: asyncio.BaseTransport) -> None: + super().connection_made(transport) + ready.set() + + transport, _protocol = await loop.create_datagram_endpoint( + lambda: _TestProtocol( + queue, _validator(cidrs=["127.0.0.0/8"]), accepting=lambda: True + ), + local_addr=("127.0.0.1", 0), + ) + await ready.wait() + port = transport.get_extra_info("sockname")[1] + + client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + client.sendto(b"integration-test", ("127.0.0.1", port)) + client.close() + + msg = await asyncio.wait_for(queue.get(), timeout=2) + transport.close() + assert isinstance(msg, SyslogMsg) + assert msg.data == "integration-test" + + +@pytest.mark.asyncio +async def test_run_async_syslog_server_graceful_shutdown( + monkeypatch: pytest.MonkeyPatch, +) -> None: + loop = asyncio.get_running_loop() + shutdown_callbacks: list[Callable[[], None]] = [] + + def capture_signal_handler( + sig: signal.Signals, callback: Callable[[], None] + ) -> None: + shutdown_callbacks.append(callback) + + monkeypatch.setattr(loop, "add_signal_handler", capture_signal_handler) + + parser = MagicMock() + parser.parse_log_line.return_value = None + + server_task = asyncio.create_task( + run_async_syslog_server( + bind_address="127.0.0.1", + port=0, + parser=parser, + process_event=lambda _event: None, + queue_maxsize=10, + shutdown_drain_seconds=1, + ) + ) + + await asyncio.sleep(0.1) + assert shutdown_callbacks + shutdown_callbacks[0]() + await asyncio.wait_for(server_task, timeout=5) + + +@pytest.mark.asyncio +async def test_run_async_syslog_server_invokes_on_shutdown_callback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + loop = asyncio.get_running_loop() + shutdown_callbacks: list[Callable[[], None]] = [] + + def capture_signal_handler( + sig: signal.Signals, callback: Callable[[], None] + ) -> None: + shutdown_callbacks.append(callback) + + monkeypatch.setattr(loop, "add_signal_handler", capture_signal_handler) + + released = {"called": False} + parser = MagicMock() + parser.parse_log_line.return_value = None + + server_task = asyncio.create_task( + run_async_syslog_server( + bind_address="127.0.0.1", + port=0, + parser=parser, + process_event=lambda _event: None, + queue_maxsize=10, + shutdown_drain_seconds=1, + on_shutdown=lambda: released.update(called=True), + ) + ) + + await asyncio.sleep(0.1) + shutdown_callbacks[0]() + await asyncio.wait_for(server_task, timeout=5) + assert released["called"] is True + + +@pytest.mark.asyncio +async def test_run_async_syslog_server_logs_shutdown_lifecycle( + monkeypatch: pytest.MonkeyPatch, +) -> None: + loop = asyncio.get_running_loop() + shutdown_callbacks: list[Callable[[], None]] = [] + + def capture_signal_handler( + sig: signal.Signals, callback: Callable[[], None] + ) -> None: + shutdown_callbacks.append(callback) + + monkeypatch.setattr(loop, "add_signal_handler", capture_signal_handler) + + info_events: list[str] = [] + import hacklog.syslog_server as syslog_server_module + + def capture_info(event: str, **kwargs: object) -> None: + info_events.append(event) + + monkeypatch.setattr(syslog_server_module.logger, "info", capture_info) + + parser = MagicMock() + parser.parse_log_line.return_value = None + server_task = asyncio.create_task( + run_async_syslog_server( + bind_address="127.0.0.1", + port=0, + parser=parser, + process_event=lambda _event: None, + queue_maxsize=10, + shutdown_drain_seconds=1, + ) + ) + + await asyncio.sleep(0.1) + shutdown_callbacks[0]() + await asyncio.wait_for(server_task, timeout=5) + + assert "shutdown_started" in info_events + assert "shutdown_complete" in info_events + + +@pytest.mark.asyncio +async def test_end_to_end_udp_parse_and_process_wo002_corpus() -> None: + """Send a WO-002 corpus syslog line over UDP and verify parse + process_event.""" + from entities import EventLog + from parse import Parser + + wo002_line = b"<14>sshd[3070]: Accepted publickey for kantselovich from 10.42.10.2 port 2005 ssh2" + parser = Parser() + processed: list[object] = [] + loop = asyncio.get_running_loop() + queue: asyncio.Queue = asyncio.Queue(maxsize=100) + running = True + + consumer_task = asyncio.create_task( + message_consumer( + queue, + parser, + processed.append, + running=lambda: running, + ) + ) + + ready = asyncio.Event() + + class _Listener(SyslogProtocol): + def connection_made(self, transport: asyncio.BaseTransport) -> None: + super().connection_made(transport) + ready.set() + + transport, _protocol = await loop.create_datagram_endpoint( + lambda: _Listener(queue, _validator(), accepting=lambda: True), + local_addr=("127.0.0.1", 0), + ) + await ready.wait() + port = transport.get_extra_info("sockname")[1] + + client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + client.sendto(wo002_line, ("127.0.0.1", port)) + client.close() + + await asyncio.sleep(0.2) + running = False + transport.close() + await asyncio.wait_for(consumer_task, timeout=2) + + assert len(processed) == 1 + assert isinstance(processed[0], EventLog) + + +@pytest.mark.asyncio +async def test_message_consumer_skips_none_parse_result() -> None: + """None from parse_log_line must not invoke process_event (WO-037).""" + queue: asyncio.Queue = asyncio.Queue(maxsize=10) + parser = MagicMock() + parser.parse_log_line.return_value = None + processed: list[object] = [] + queue.put_nowait(SyslogMsg("unparseable", "127.0.0.1", 1)) + running = True + + async def consume_until_stopped() -> None: + nonlocal running + await message_consumer( + queue, + parser, + processed.append, + running=lambda: running, + ) + + task = asyncio.create_task(consume_until_stopped()) + await asyncio.sleep(0.1) + running = False + await task + + assert processed == [] + parser.parse_log_line.assert_called_once() + + +@pytest.mark.asyncio +async def test_message_consumer_exits_when_running_false() -> None: + """Consumer loop terminates when running is False and the queue is empty (WO-037).""" + queue: asyncio.Queue = asyncio.Queue(maxsize=10) + parser = MagicMock() + + await asyncio.wait_for( + message_consumer( + queue, + parser, + lambda _event: None, + running=lambda: False, + ), + timeout=1, + ) + + parser.parse_log_line.assert_not_called() diff --git a/tests/test_validators.py b/tests/test_validators.py new file mode 100644 index 0000000..caea84a --- /dev/null +++ b/tests/test_validators.py @@ -0,0 +1,147 @@ +"""Unit and integration tests for hacklog.validators.""" + +import pytest + +from hacklog.entities import IpLocation, SyslogMsg +from hacklog.metrics import messages_dropped_total +from hacklog.parse import Parser +from hacklog.validators import ( + FieldValidationResult, + sanitize_for_log, + validate_hostname, + validate_ip_address, + validate_parsed_fields, + validate_username, +) +from tests.fixtures.injection_messages import ( + INJECTION_SYSLOG_FIXTURES, + VALID_SYSLOG_FIXTURES, +) + + +@pytest.mark.parametrize( + ("value", "expected_valid"), + [ + ("alice", True), + ("user_1", True), + ("admin-user", True), + ("admin'; DROP TABLE users;--", False), + ("$(whoami)", False), + ("admin)(|(password=*))", False), + ("user\nname", False), + ("user\x00name", False), + ("", False), + ], +) +def test_validate_username(value: str, expected_valid: bool) -> None: + result = validate_username(value) + assert isinstance(result, FieldValidationResult) + assert result.valid is expected_valid + + +@pytest.mark.parametrize( + ("value", "expected_valid"), + [ + ("10.42.10.2", True), + ("192.168.1.1", True), + ("2001:db8::1", True), + ("999.999.999.999", False), + ("not-an-ip", False), + ("10.0.0.1'; DROP TABLE users;--", False), + ("10.0.0.1\n", False), + ], +) +def test_validate_ip_address(value: str, expected_valid: bool) -> None: + result = validate_ip_address(value) + assert result.valid is expected_valid + + +@pytest.mark.parametrize( + ("value", "expected_valid"), + [ + ("prod-web-01", True), + ("ae1-app80-prd", True), + ("host.example.com", True), + ("bad host", False), + ("host;rm -rf /", False), + ("host\nname", False), + ], +) +def test_validate_hostname(value: str, expected_valid: bool) -> None: + result = validate_hostname(value) + assert result.valid is expected_valid + + +def test_validate_parsed_fields_increments_invalid_field_counter() -> None: + before = messages_dropped_total.labels( + reason="invalid_field" + )._value.get() # noqa: SLF001 + assert validate_parsed_fields("bad user", "10.0.0.1", "host1") is False + after = messages_dropped_total.labels( + reason="invalid_field" + )._value.get() # noqa: SLF001 + assert after - before == 1.0 + + +def test_validate_parsed_fields_accepts_valid_triplet() -> None: + assert validate_parsed_fields("alice", "10.42.10.2", "prod-web-01") is True + + +def test_sanitize_for_log_escapes_control_characters() -> None: + assert "\\x00" in sanitize_for_log("a\x00b") + + +@pytest.mark.parametrize( + ("ip_address", "vpn", "internal"), + [ + ("10.42.1.5", True, False), + ("10.24.1.5", False, True), + ("10.26.1.5", False, True), + ("172.16.1.5", False, True), + ("203.0.113.5", False, False), + ], +) +def test_ip_address_entity_checks_work_with_validated_ips( + ip_address: str, vpn: bool, internal: bool +) -> None: + assert validate_ip_address(ip_address).valid is True + assert IpLocation.check_ip_for_vpn(ip_address) is vpn + assert IpLocation.check_ip_for_internal(ip_address) is internal + + +@pytest.mark.parametrize( + ("fixture_name", "expected_parsed"), + [ + ("success_ssh", True), + ("failure_ssh", True), + ("sql_username", False), + ("shell_username", False), + ("ldap_username", False), + ("null_byte_username", False), + ("invalid_ip", False), + ], +) +def test_parser_rejects_injection_payloads( + fixture_name: str, expected_parsed: bool +) -> None: + parser = Parser(validate_fields=True) + fixtures = {**VALID_SYSLOG_FIXTURES, **INJECTION_SYSLOG_FIXTURES} + message = SyslogMsg(fixtures[fixture_name], "127.0.0.1") + event = parser.parse_log_line(message) + if expected_parsed: + assert event is not None + else: + assert event is None + + +def test_parser_integration_rejects_invalid_ip_before_database_layer() -> None: + parser = Parser(validate_fields=True) + before = messages_dropped_total.labels( + reason="invalid_field" + )._value.get() # noqa: SLF001 + message = SyslogMsg(INJECTION_SYSLOG_FIXTURES["invalid_ip"], "127.0.0.1") + assert parser.parse_log_line(message) is None + after = messages_dropped_total.labels( + reason="invalid_field" + )._value.get() # noqa: SLF001 + assert after - before >= 1.0