tirreno for developers

June 5, 2026 · View on GitHub

Crash course for new tirreno developers

Welcome

Welcome and thank you for your interest in tirreno open-source security framework. The tirreno community is open and we welcome contributions of code and ideas.

tirreno is available in three editions:

  • Community Edition (open-source): For developer teams that want to add a security layer to self-hosted applications. Get started today without getting into complex business relationships. Licensed under GNU Affero General Public License v3 (AGPL-3.0).

  • Application Edition: Protects your organization's internal applications from account threats, ensures audit trails and field history for compliance, detects data exfiltration and insider threats.

  • Platform Edition: Built for client portals, SaaS, public sector portals, and digital platforms. Multi-application support, fraud and abuse prevention, and dedicated assistance for your SOC, product, and risk teams.

For Application and Platform editions, contact team@tirreno.com.

     Community                  Application              Platform
     Edition                    Edition                  Edition
         │                         │                         │
         ▼                         ▼                         ▼
    Personal apps             Internal apps            External-facing
    + Basic security          + Compliance             + Multi-app
    + Development             + Audit trails           + Fraud/abuse
    + No support              + Insider threats        + Dedicated support

Here is some basic information for new developers to get up and running quickly:


Table of contents

  1. System architecture

  2. API integration

  3. Integration guide

  4. Risk rules & customization

  5. Contributing

  6. Resources

  7. Found a mistake?


System architecture

Introduction

tirreno is a PHP/PostgreSQL application. Lightweight MVC for safety analytics, security analytics and threat detection.

Overview

 ┌──────────┐      request       ┌─────────────────┐      POST /sensor/       ┌─────────────────┐
 │   User   │ ─────────────────▶ │    Your App     │ ────────────────────────▶│    tirreno      │
 └──────────┘                    │  (allow/deny)   │◀──────────────────────── │  + Risk scoring │
                                 └─────────────────┘      response            │  + Rule engine  │
                                                                              │  + Blacklist    │
                                                                              └─────────────────┘

Your application sends user events (logins, registrations, page views, field changes) to tirreno. tirreno analyzes the events, calculates risk scores, and can automatically blacklist suspicious users. Your app can query the blacklist API to block bad actors in real-time.

Technology stack

Core dependencies (composer.json):

DependencyWhat it does
bcosca/fatfree-coreFat-Free Framework (F3)
matomo/device-detectorDevice/browser/OS detection
ruler/rulerRules engine

Dev tools: phpstan (static analysis), php_codesniffer (style)

System requirements

  • PHP 8.0–8.3 with PDO_PGSQL, cURL, mbstring
  • PostgreSQL 12+
  • Apache with mod_rewrite

Hardware: 512 MB RAM for PostgreSQL (4 GB recommended), ~3 GB storage per 1M events.

Directory structure

tirreno/

├── .github/                    # GitHub configuration
│   ├── workflows/              # CI/CD workflows
│   │   └── ci.yml              # Continuous integration
│   └── actions/                # Custom GitHub actions

├── tests/                      # Test suites
│   ├── Unit/                   # Unit tests
│   └── Support/                # Test support files

├── app/                        # Application code
│   ├── Assets/                 # Rule base classes
│   │   └── Rule.php            # Abstract Rule class
│   │
│   ├── Controllers/            # Request handlers
│   │   ├── Admin/              # Admin panel controllers
│   │   │   ├── Base/           # Base controller classes
│   │   │   ├── Events/         # Events module
│   │   │   ├── Rules/          # Rules module
│   │   │   ├── Users/          # Users module
│   │   │   └── ...             # Other admin modules
│   │   ├── Api/                # API controllers
│   │   │   ├── Blacklist.php   # Blacklist API
│   │   │   └── Endpoint.php    # API endpoint handler
│   │   ├── Pages/              # Page controllers
│   │   │   ├── Login.php
│   │   │   ├── Signup.php
│   │   │   └── ...
│   │   ├── Cron.php            # Cron controller
│   │   └── Navigation.php      # Navigation controller
│   │
│   ├── Crons/                  # Background job handlers
│   │   ├── Base.php            # Base cron class
│   │   ├── BatchedNewEvents.php
│   │   ├── EnrichmentQueueHandler.php
│   │   ├── RiskScoreQueueHandler.php
│   │   └── ...                 # Other cron jobs
│   │
│   ├── Dictionary/             # Internationalization (i18n)
│   │   └── en/                 # English translations
│   │       ├── Pages/          # Page-specific translations
│   │       ├── Parts/          # Component translations
│   │       └── All.php         # Combined translations
│   │
│   ├── Interfaces/             # PHP interfaces
│   │   ├── ApiKeyAccessAuthorizationInterface.php
│   │   ├── ApiKeyAccountAccessAuthorizationInterface.php
│   │   └── FraudFlagUpdaterInterface.php
│   │
│   ├── Models/                 # Database models (extend BaseSql)
│   │   ├── BaseSql.php         # Base class with execQuery()
│   │   ├── Device.php          # Device/user-agent model
│   │   ├── Grid/               # Grid data models
│   │   ├── Chart/              # Chart data models
│   │   ├── Enrichment/         # Enrichment models
│   │   └── ...                 # Other models
│   │
│   ├── Updates/                # Database migration handlers
│   │
│   ├── Utils/                  # Utility classes
│   │   ├── ApiKeys.php         # API key utilities
│   │   ├── Constants.php       # Application constants
│   │   ├── Logger.php          # Logging utilities
│   │   ├── Rules.php           # Rule utilities
│   │   └── ...                 # Other utilities
│   │
│   └── Views/                  # View helpers

├── assets/                     # Static assets and rules
│   ├── rules/                  # Rules engine
│   │   ├── core/               # Core rule definitions
│   │   └── custom/             # Custom rule definitions
│   ├── lists/                  # Suspicious pattern lists
│   │   ├── url.php             # URL attack patterns
│   │   ├── user-agent.php      # User agent patterns
│   │   ├── email.php           # Email patterns
│   │   └── file-extensions.php # File extension categories
│   ├── logs/                   # Application logs
│   └── ...                     # CSS, images

├── config/                     # Configuration files
│   ├── config.ini              # Main configuration
│   ├── routes.ini              # Route definitions
│   ├── apiEndpoints.ini        # API endpoint definitions
│   ├── crons.ini               # Cron job configuration
│   └── local/                  # Local overrides

├── install/                    # Web-based installation wizard
│   └── index.php               # DELETE AFTER INSTALLATION

├── libs/                       # Third-party libraries (vendor)

├── sensor/                     # API endpoint for event ingestion

├── tmp/                        # Temporary files, cache

├── ui/                         # Frontend UI
│   ├── css/                    # Stylesheets
│   ├── images/                 # Static images
│   │   ├── icons/
│   │   └── flags/
│   ├── js/                     # JavaScript files
│   │   ├── endpoints/          # Page entry points
│   │   ├── pages/              # Page controllers
│   │   │   ├── Base.js         # Base page class
│   │   │   ├── Ips.js          # IPs page
│   │   │   ├── Events.js       # Events page
│   │   │   └── ...             # Other pages
│   │   ├── parts/              # Reusable components
│   │   │   ├── grid/           # Data grid components
│   │   │   ├── chart/          # Chart components (uPlot)
│   │   │   ├── panel/          # Detail panel components
│   │   │   ├── choices/        # Filter components (Choices.js)
│   │   │   ├── details/        # Detail view components
│   │   │   ├── popup/          # Popup/modal components
│   │   │   ├── utils/          # Utility modules
│   │   │   │   ├── Constants.js
│   │   │   │   ├── String.js
│   │   │   │   └── Date.js
│   │   │   └── ...             # Other components
│   │   └── vendor/             # Third-party JS libraries
│   │       ├── jquery-3.6.0/
│   │       ├── datatables-2.3.2/
│   │       ├── uPlot-1.6.18/
│   │       ├── choices-10.2.0/
│   │       ├── jvectormap-2.0.5/
│   │       ├── tooltipster-master-4.2.8/
│   │       ├── accept-language-parser-1.5.0/
│   │       └── devbridge-jquery-autocomplete-1.5.0/
│   └── templates/              # HTML templates
│       ├── layout.html         # Base layout
│       ├── pages/              # Page templates
│       │   ├── admin/          # Admin page templates
│       │   │   ├── events.html
│       │   │   ├── ip.html
│       │   │   ├── users.html
│       │   │   └── ...
│       │   ├── login.html
│       │   ├── signup.html
│       │   └── ...
│       ├── parts/              # Component templates
│       │   ├── headerAdmin.html
│       │   ├── footerAdmin.html
│       │   ├── leftMenu.html
│       │   ├── notification.html
│       │   ├── forms/
│       │   ├── panel/
│       │   ├── tables/
│       │   ├── widgets/
│       │   └── choices/
│       └── snippets/           # Code snippets (PHP, Python, etc.)

├── index.php                   # Application entry point
├── .htaccess                   # Apache URL rewriting rules
├── .profile                    # Environment profile
├── composer.json               # PHP dependencies
├── composer.lock               # Locked dependency versions
├── cron.json                   # Cron job definitions
├── phpcs.xml                   # PHP CodeSniffer configuration
├── eslint.config.js            # JavaScript linting configuration

├── AUTHORS.md                  # Project contributors
├── CHANGELOG.md                # Version history
├── CODE_OF_CONDUCT.md          # Community guidelines
├── LICENSE                     # AGPL-3.0 license
├── LEGALNOTICE.md              # Legal notices
├── README.md                   # Project overview
├── RELEASE_NOTES.md            # Release notes
├── SECURITY.md                 # Security policy
├── FILE_ID.DIZ                 # BBS-style file description
└── robots.txt                  # Search engine directives

API integration

Event ingestion happens through sensors that collect the events, and they all get into the queue. Then, when the cron starts (in loop-until-drained), it defines the users that had actions since the last cron run, updates statistics, and calculates the context that is used by the active rules to determine a final risk score for each user.

Official tracker libraries

Use one of these:

LanguageInstall
PHPcomposer require tirreno/tirreno-tracker
Pythonpip install tirreno_tracker
Node.jsnpm install @tirreno/tirreno-tracker

Repos: PHP, Python, Node.js

API reference

Endpoint

POST /sensor/
Content-Type: application/x-www-form-urlencoded
Api-Key: YOUR_API_KEY

Note: The API uses form-urlencoded format, not JSON.

Required parameters

ParameterDescription
userNameUnique user ID (max 100 chars)
ipAddressIPv4/IPv6 address (invalid IPs default to 0.0.0.0)
urlURL path (max 2047 chars)
eventTimeTimestamp Y-m-d H:i:s.v (defaults to current UTC if invalid)

Optional parameters

ParameterTypeDescription
emailAddressstringEmail address (max 255 chars). Validated and converted to lowercase
userAgentstringBrowser UA (max 511 chars)
firstNamestringUser's first name (max 100 chars)
lastNamestringUser's last name (max 100 chars)
fullNamestringUser's whole name (max 100 chars)
pageTitlestringTitle of visited page (max 255 chars)
phoneNumberstringUser's phone number (max 19 chars)
httpRefererstringReferer HTTP header value (max 2047 chars)
httpMethodstringHTTP method: GET, POST, HEAD, PUT, DELETE, PATCH, TRACE, CONNECT, OPTIONS, LINK, UNLINK
httpCodestringHTTP response status code (must be numeric, defaults to 0)
browserLanguagestringDetected browser language (max 255 chars)
eventTypestringOne of the event types listed below. Defaults to page_view, or page_error if httpCode >= 400
userCreatedstringUser creation timestamp (Y-m-d H:i:s or Y-m-d H:i:s.v)
payloadarrayEvent details for page_search events
fieldHistoryarrayField edit history for field_edit events

Note: Maximum length for all other parameters is 100 characters unless specified above. Parameters exceeding max length are truncated.

Event types

Default: page_view (or page_error if httpCode >= 400)

TypeDescription
page_viewPage visit (default)
page_editPage modification
page_deletePage deletion
page_searchSearch query
page_errorError page
account_loginUser authentication
account_logoutSession end
account_login_failFailed login attempt
account_registrationNew account creation
account_email_changeEmail address change
account_password_changePassword change
account_editAccount profile modification
field_editData modification

Payload parameter

For page_search events:

{
    "eventType": "page_search",
    "payload": {
        "field_id": 179280,
        "value": "search query",
        "field_name": "Country"
    }
}
FieldRequiredDescription
field_idYesUnique identifier for the search field
valueYesThe search query string
field_nameNoHuman-readable field name

Field history parameter

Required for field_edit events. Must be an array of field change objects:

{
    "eventType": "field_edit",
    "fieldHistory": [
        {
            "field_id": 179283,
            "new_value": "Paris",
            "field_name": "User city",
            "old_value": "London",
            "parent_id": "",
            "parent_name": ""
        }
    ]
}
FieldRequiredDescription
field_idYesUnique identifier for the field
new_valueYesThe new field value
field_nameNoHuman-readable field name
old_valueNoThe previous field value
parent_idNoParent record ID (for nested data)
parent_nameNoParent record name

Note: Missing required fields default to "unknown". All values are converted to strings.

Blacklist API

Check if a user or IP is blacklisted:

Request:

POST /api/v1/blacklist/search
Content-Type: application/json
Api-Key: YOUR_API_KEY

{
    "value": "username_or_ip"
}

Response:

{
    "value": "username_or_ip",
    "blacklisted": false
}

Note: Successful requests (2xx) return no response body.

Logbook event types

The Logbook page in tirreno dashboard tracks all API requests with these status codes:

StatusDescription
SuccessEvent recorded successfully
Validation warningEvent recorded with field corrections (e.g., truncated values)
Critical validation errorEvent rejected due to missing required fields
Critical errorServer error, event not recorded
Rate limit exceededRequest rejected due to rate limiting (LEAKY_BUCKET_RPS & LEAKY_BUCKET_WINDOW in `/config/config.ini)

Integration guide

This section covers integrating tirreno into your application.

Why send events to tirreno?

tirreno analyzes user events to detect threats and calculate risk scores. Use cases:

  • Security monitoring: Detect account takeover, brute force attacks, suspicious behavior
  • Threat hunting: Search for indicators of compromise across user activity
  • Insider threats: Spot unusual employee behavior, potential data exfiltration
  • Compliance: Activity logs and field audit trail for GDPR, SOC 2, PCI-DSS
  • Forensics: Investigate incidents with full session history
  • Risk scoring: Calculate user trust scores from behavior patterns
  • Fraud prevention: Block malicious users before damage occurs
  • IP enrichment: Add geolocation, ISP, VPN/proxy detection to IP data

tirreno tracks per-user metrics: devices per day, IPs per day, sessions, events per session.

IP Enrichment API: tirreno provides an API for IP geolocation and threat intelligence. The open-source Community Edition includes an optional IP enrichment pack (2,000 free API requests/month). For high-volume needs, contact tirreno for Enterprise options. See tirreno.com for pricing details.

Integration planning

Application Edition: For internal applications we recommend to use existing integrations. Check the list of available integrations or contact tirreno at team@tirreno.com for further details.

What to track

Fraud VectorHow tirreno DetectsEvents
Stolen credentialsMultiple IPs, unusual locationsaccount_login, account_login_fail
Account sharingConcurrent sessions, device changespage_view, account_login
Fake accountsDisposable emails, VPN/proxyaccount_registration
Data scrapingHigh volume, bot signaturespage_view, page_search
Privilege abuseOff-hours, sensitive operationsaccount_edit, field_edit

Where to integrate

Minimum:

  • Login/logout
  • Failed login attempts
  • Registration
  • Password/email changes

Recommended:

  • Authenticated page views
  • Search queries
  • Data modifications
  • File downloads/exports
  • Admin actions

Data you need

DataSourceRequired
User IDAuth systemYes
IP addressRequest headersYes
URLRequest pathYes
TimestampServer time (UTC)Yes
EmailUser profileRecommended
User agentHeadersRecommended

Technical notes

Performance:

  • Use async/non-blocking HTTP calls
  • Set 3-5 second timeouts
  • Queue events during high traffic
  • Don't block user actions on tirreno response

Reliability:

  • Implement retry with exponential backoff
  • Fail open (allow user action if tirreno unavailable)
  • Log failed events locally for debugging

Privacy:

  • Never send passwords or tokens
  • Hash sensitive IDs if needed
  • Document data collection in privacy policy
  • Consider GDPR requirements for EU users

Scalability:

  • One event per significant user action
  • Batch events where appropriate
  • Monitor tirreno logbook for errors

Security considerations

When integrating tirreno, follow these security best practices:

  1. Install in private environment Deploy tirreno in a private network with controlled access
  2. Protect your API key Store in environment variables, never in code
  3. Use HTTPS Always send events over encrypted connections
  4. Don't log sensitive data Never include passwords, tokens, or PII in event payloads
  5. Fail open on errors Don't block users if tirreno is temporarily unavailable
  6. Set timeouts Use 3-5 second timeouts to prevent login delays
  7. Validate on your side tirreno is for monitoring, not input validation
  8. Send timestamps in UTC All eventTime values must be in UTC. Configure your tirreno instance timezone during initial setup or in Settings → Time zone. The dashboard will display events in your configured timezone, but all data sent via the API must use UTC

Quick start

Important: tirreno must be integrated on the backend only. Never send events from frontend JavaScript or mobile apps. Client-side code can be inspected, modified, or bypassed entirely — attackers could disable tracking, forge events, or extract your API key. Backend integration ensures event data cannot be tampered with and your API credentials remain secure.

The fastest way to integrate tirreno is using an official tracker library.

cURL (raw API):

curl -X POST https://your-tirreno-instance.com/sensor/ \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "key=your-api-key" \
  -d "userName=user123" \
  -d "emailAddress=user@example.com" \
  -d "ipAddress=192.168.1.100" \
  -d "url=/login" \
  -d "eventTime=2024-12-08 14:30:00.000" \
  -d "eventType=page_view"

PHP:

Requirements: cURL PHP extension

Installation:

composer require tirreno/tirreno-tracker

Or manually via file download:

require_once("TirrenoTracker.php");

Usage:

<?php

// Load object
require_once("TirrenoTracker.php");

$tirrenoUrl = "https://example.tld/sensor/"; // Sensor URL
$trackingId = "XXX"; // Tracking ID

// Create object
$tracker = new TirrenoTracker($tirrenoUrl, $trackingId);

// Override defaults of required params
$tracker->setUserName("johndoe42")
        ->setIpAddress("1.1.1.1")
        ->setUrl("/login")
        ->setUserAgent("Mozilla/5.0 (X11; Linux x86_64)")
        ->setEventTypeAccountLogin();

// Set optional params
$tracker->setFirstName("John")
        ->setBrowserLanguage("fr-FR,fr;q=0.9")
        ->setHttpMethod("POST");

// Track event
$tracker->track();

Python:

pip install tirreno_tracker

from tirreno_tracker import Tracker

tracker = Tracker('https://your-tirreno-instance.com', 'your-api-key')

# Track a login
event = tracker.create_event()

event.set_user_name(user_id) \
     .set_email_address(user_email) \
     .set_ip_address(ip_address) \
     .set_url(url_path) \
     .set_user_agent(user_agent) \
     .set_event_type_account_login()

tracker.track(event)

Node.js:

npm install @tirreno/tirreno-tracker

const Tracker = require('@tirreno/tirreno-tracker');

const tracker = new Tracker('https://your-tirreno-instance.com', 'your-api-key');

// Track a registration
const event = tracker.createEvent();

event.setUserName(userId)
     .setEmailAddress(userEmail)
     .setIpAddress(ipAddress)
     .setUrl(urlPath)
     .setUserAgent(userAgent)
     .setEventTypeAccountRegistration();

await tracker.track(event);

Event tracking best practices

Which events to track

Essential events (always track):

EventWhen to TrackWhy It Matters
account_loginSuccessful authenticationDetect account takeover
account_login_failFailed login attemptsDetect brute force attacks
account_registrationNew account creationDetect fake account creation
account_password_changePassword updatesDetect account compromise
account_email_changeEmail changesDetect account hijacking

Recommended events:

EventWhen to TrackWhy It Matters
page_viewKey page visitsBehavioral analysis
page_editContent modificationsDetect malicious edits
page_searchSearch queriesDetect reconnaissance
page_error4xx/5xx errorsDetect scanning/attacks
field_editData modificationField audit trail

Data quality guidelines

  1. Consistent user identifiers:
// Good - use permanent ID
$tracker->setUserName($user->id);

// Bad - don't use changing values
$tracker->setUserName($user->email);  // Emails can change
  1. Accurate timestamps:

The tracker libraries automatically set eventTime to the current UTC timestamp with milliseconds when you call track(). For manual timestamp handling, use the format Y-m-d H:i:s.v:

// PHP - include milliseconds
$eventTime = date('Y-m-d H:i:s.v');  // 2024-01-15 10:30:45.123
  1. Real IP addresses:
// Good - handle proxies correctly
function getRealIp(): string {
    $headers = ['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR'];
    foreach ($headers as $header) {
        if (!empty($_SERVER[$header])) {
            $ips = explode(',', $_SERVER[$header]);
            return trim($ips[0]);
        }
    }
    return $_SERVER['REMOTE_ADDR'];
}

$tracker->setIpAddress(getRealIp());
  1. Complete user agent:
// Good - full user agent
$tracker->setUserAgent($_SERVER['HTTP_USER_AGENT']);

// Bad - truncated
$tracker->setUserAgent(substr($_SERVER['HTTP_USER_AGENT'], 0, 50));

Send all logged-in user events

Track page views and actions from authenticated users.

PHP:

session_start();

if (isset($_SESSION['user_id'])) {
    $tracker->setUserName((string) $_SESSION['user_id'])
            ->setEmailAddress($_SESSION['user_email'])
            ->setIpAddress($_SERVER['REMOTE_ADDR'])
            ->setUrl($_SERVER['REQUEST_URI'])
            ->setUserAgent($_SERVER['HTTP_USER_AGENT'] ?? '')
            ->setHttpMethod($_SERVER['REQUEST_METHOD'])
            ->setEventTypePageView();

    $tracker->track();
}

Node.js:

if (userId) {
    const event = tracker.createEvent();

    event.setUserName(userId)
         .setEmailAddress(userEmail)
         .setIpAddress(ipAddress)
         .setUrl(urlPath)
         .setUserAgent(userAgent)
         .setHttpMethod(httpMethod)
         .setHttpCode(httpCode.toString())
         .setEventTypePageView();

    await tracker.track(event);
}

Python:

if user_id:
    event = tracker.create_event()

    event.set_user_name(str(user_id)) \
         .set_email_address(user_email) \
         .set_ip_address(ip_address) \
         .set_url(url_path) \
         .set_user_agent(user_agent) \
         .set_http_method(http_method) \
         .set_http_code(str(http_code)) \
         .set_event_type_page_view()

    tracker.track(event)

Protecting the registration

Protect your registration flow from fake accounts, bots, and abuse.

Track registration events

PHP:

$userId = createUser($_POST['email'], $_POST['password'], $_POST['name']);

$tracker->setUserName((string) $userId)
        ->setEmailAddress($_POST['email'])
        ->setFullName($_POST['name'])
        ->setIpAddress($_SERVER['REMOTE_ADDR'])
        ->setUrl('/register')
        ->setUserAgent($_SERVER['HTTP_USER_AGENT'] ?? '')
        ->setUserCreated(date('Y-m-d H:i:s'))
        ->setEventTypeAccountRegistration();

$tracker->track();

header('Location: /dashboard');

Python:

user_id = create_user(email, password, name)

event = tracker.create_event()

event.set_user_name(str(user_id)) \
     .set_email_address(email) \
     .set_full_name(name) \
     .set_ip_address(ip_address) \
     .set_url('/register') \
     .set_user_agent(user_agent) \
     .set_event_type_account_registration()

tracker.track(event)

Node.js:

const userId = await createUser(email, password, name);

const event = tracker.createEvent();

event.setUserName(userId.toString())
     .setEmailAddress(email)
     .setFullName(name)
     .setIpAddress(ipAddress)
     .setUrl('/register')
     .setUserAgent(userAgent)
     .setEventTypeAccountRegistration();

await tracker.track(event);

Protecting the login

Secure your login flow against brute force attacks and credential stuffing.

Track login events

PHP:

$email = $_POST['email'];
$password = $_POST['password'];

$user = authenticateUser($email, $password);

if (!$user) {
    // Track failed login
    $tracker->setUserName($email)
            ->setEmailAddress($email)
            ->setIpAddress($_SERVER['REMOTE_ADDR'])
            ->setUrl('/login')
            ->setUserAgent($_SERVER['HTTP_USER_AGENT'] ?? '')
            ->setEventTypeAccountLoginFail();

    $tracker->track();

    die('Invalid credentials');
}

// Track successful login
$tracker->setUserName((string) $user['id'])
        ->setEmailAddress($user['email'])
        ->setIpAddress($_SERVER['REMOTE_ADDR'])
        ->setUrl('/login')
        ->setUserAgent($_SERVER['HTTP_USER_AGENT'] ?? '')
        ->setEventTypeAccountLogin();

$tracker->track();

session_start();
$_SESSION['user_id'] = $user['id'];
header('Location: /dashboard');

Python:

user = authenticate_user(email, password)

if not user:
    # Track failed login
    event = tracker.create_event()

    event.set_user_name(email) \
         .set_email_address(email) \
         .set_ip_address(ip_address) \
         .set_url('/login') \
         .set_user_agent(user_agent) \
         .set_event_type_account_login_fail()

    tracker.track(event)
    # Return error
else:
    # Track successful login
    event = tracker.create_event()

    event.set_user_name(str(user['id'])) \
         .set_email_address(user['email']) \
         .set_ip_address(ip_address) \
         .set_url('/login') \
         .set_user_agent(user_agent) \
         .set_event_type_account_login()

    tracker.track(event)

Node.js:

const user = await authenticateUser(email, password);

if (!user) {
    // Track failed login
    const event = tracker.createEvent();

    event.setUserName(email)
         .setEmailAddress(email)
         .setIpAddress(ipAddress)
         .setUrl('/login')
         .setUserAgent(userAgent)
         .setEventTypeAccountLoginFail();

    await tracker.track(event);
    // Return error
} else {
    // Track successful login
    const event = tracker.createEvent();

    event.setUserName(user.id.toString())
         .setEmailAddress(user.email)
         .setIpAddress(ipAddress)
         .setUrl('/login')
         .setUserAgent(userAgent)
         .setEventTypeAccountLogin();

    await tracker.track(event);
}

Block blacklisted users

Check the blacklist API before allowing login:

$email = $_POST['email'];
$password = $_POST['password'];

// Block known attackers before authentication
if ($blacklistService->isBlacklisted($email)) {
    die('Invalid credentials');
}

$user = authenticateUser($email, $password);

if (!$user) {
    $tracker->setUserName($email)
            ->setEmailAddress($email)
            ->setIpAddress($_SERVER['REMOTE_ADDR'])
            ->setUrl('/login')
            ->setUserAgent($_SERVER['HTTP_USER_AGENT'] ?? '')
            ->setEventTypeAccountLoginFail();

    $tracker->track();
    die('Invalid credentials');
}

// Also check authenticated user
if ($blacklistService->isBlacklisted((string) $user['id'])) {
    die('Invalid credentials');
}

// Track successful login
$tracker->setUserName((string) $user['id'])
        ->setEmailAddress($user['email'])
        ->setIpAddress($_SERVER['REMOTE_ADDR'])
        ->setUrl('/login')
        ->setUserAgent($_SERVER['HTTP_USER_AGENT'] ?? '')
        ->setEventTypeAccountLogin();

$tracker->track();

session_start();
$_SESSION['user_id'] = $user['id'];
header('Location: /dashboard');

Auto-ban abusive IPs

Use tirreno's IP analysis combined with the blacklist API for automatic protection.

Configure threshold settings

Before implementing auto-ban, configure and test the threshold settings in tirreno:

  1. Go to Rules page in tirreno dashboard
  2. Set Manual review threshold (e.g., 33) users below this score appear in review queue
  3. Set Auto-blacklisting threshold (e.g., 20) users below this score are automatically blacklisted
  4. Click Update to save settings

Middleware for IP-based blocking

PHP:

$ip = $_SERVER['REMOTE_ADDR'];

if ($blacklistService->isBlacklisted($ip)) {
    http_response_code(403);
    die('Access denied');
}

Python:

if blacklist_service.is_blacklisted(ip_address):
    # Return 403 Access denied
    pass

Node.js:

if (await blacklistService.isBlacklisted(ipAddress)) {
    // Return 403 Access denied
}

Field audit trail

Track changes to important user fields for compliance, security, and regulatory requirements. The fieldHistory parameter allows you to send detailed change records.

Field history format

Each field change object has these properties:

PropertyRequiredTypeDescription
field_idYesint/stringUnique identifier for the field
new_valueYesstringNew value
field_nameNostringHuman-readable field name
old_valueNostringPrevious value
parent_idNostringParent record ID (for nested data)
parent_nameNostringParent record name

Note: Missing required fields default to "unknown". All values are converted to strings.

PHP:

function trackFieldChanges($userId, $userEmail, $oldData, $newData, $tracker) {
    $trackableFields = [
        'city' => 'User city',
        'phone' => 'Phone number',
        'address' => 'Address',
        'company' => 'Company name',
    ];

    $changes = [];
    foreach ($trackableFields as $field => $fieldName) {
        $oldValue = $oldData[$field] ?? '';
        $newValue = $newData[$field] ?? '';

        if ($oldValue !== $newValue) {
            $changes[] = [
                'field_id' => crc32($field),
                'field_name' => $fieldName,
                'old_value' => (string) $oldValue,
                'new_value' => (string) $newValue,
                'parent_id' => '',
                'parent_name' => '',
            ];
        }
    }

    if (!empty($changes)) {
        $tracker->setUserName((string) $userId)
                ->setEmailAddress($userEmail)
                ->setIpAddress($_SERVER['REMOTE_ADDR'])
                ->setUrl($_SERVER['REQUEST_URI'])
                ->setUserAgent($_SERVER['HTTP_USER_AGENT'] ?? '')
                ->setEventTypeFieldEdit()
                ->setFieldHistory($changes);

        $tracker->track();
    }
}

// Usage
$oldData = getUserById($userId);
updateUser($userId, $_POST);
trackFieldChanges($userId, $userEmail, $oldData, $_POST, $tracker);

Python:

def track_field_changes(user_id, user_email, old_data, new_data, tracker):
    trackable_fields = {
        'city': 'User city',
        'phone': 'Phone number',
        'address': 'Address',
        'company': 'Company name',
    }

    changes = []
    for field, field_name in trackable_fields.items():
        old_value = old_data.get(field, '')
        new_value = new_data.get(field, '')

        if old_value != new_value:
            changes.append({
                'field_id': hash(field) & 0xffffffff,
                'field_name': field_name,
                'old_value': str(old_value),
                'new_value': str(new_value),
                'parent_id': '',
                'parent_name': '',
            })

    if changes:
        event = tracker.create_event()

        event.set_user_name(str(user_id)) \
             .set_email_address(user_email) \
             .set_ip_address(ip_address) \
             .set_url(url_path) \
             .set_user_agent(user_agent) \
             .set_event_type_field_edit() \
             .set_field_history(changes)

        tracker.track(event)

# Usage
old_data = get_user_by_id(user_id)
update_user(user_id, new_data)
track_field_changes(user_id, user_email, old_data, new_data, tracker)

Node.js:

async function trackFieldChanges(userId, userEmail, oldData, newData, tracker) {
    const trackableFields = {
        city: 'User city',
        phone: 'Phone number',
        address: 'Address',
        company: 'Company name',
    };

    const changes = [];
    for (const [field, fieldName] of Object.entries(trackableFields)) {
        const oldValue = oldData[field] ?? '';
        const newValue = newData[field] ?? '';

        if (oldValue !== newValue) {
            changes.push({
                field_id: hashCode(field),
                field_name: fieldName,
                old_value: String(oldValue),
                new_value: String(newValue),
                parent_id: '',
                parent_name: ''
            });
        }
    }

    if (changes.length > 0) {
        const event = tracker.createEvent();

        event.setUserName(userId.toString())
             .setEmailAddress(userEmail)
             .setIpAddress(ipAddress)
             .setUrl(urlPath)
             .setUserAgent(userAgent)
             .setEventTypeFieldEdit()
             .setFieldHistory(changes);

        await tracker.track(event);
    }
}

// Usage
const oldData = await getUserById(userId);
await updateUser(userId, newData);
await trackFieldChanges(userId, userEmail, oldData, newData, tracker);

Tracking nested/related data:

// For related records (e.g., user addresses)
$changes = [];

foreach ($updatedAddresses as $address) {
    $original = $originalAddresses->find($address->id);

    foreach (['street', 'city', 'zip'] as $field) {
        if ($original->$field !== $address->$field) {
            $changes[] = [
                'field_id' => crc32($field),
                'field_name' => ucfirst($field),
                'old_value' => $original->$field,
                'new_value' => $address->$field,
                'parent_id' => (string) $address->id,      // Link to address record
                'parent_name' => "Address #{$address->id}", // Human-readable reference
            ];
        }
    }
}

Testing your integration

Manual testing checklist

  1. Verify API connectivity:
curl -X POST https://your-tirreno.com/sensor/ \
  -H "Api-Key: your-api-key" \
  -d "userName=test-user-123" \
  -d "emailAddress=test@example.com" \
  -d "ipAddress=203.0.113.50" \
  -d "url=/test" \
  -d "userAgent=Mozilla/5.0 Test" \
  -d "eventTime=2024-12-08 01:01:00.000" \
  -d "eventType=page_view"
  1. Check the Logbook:

    • Log in to your tirreno instance
    • Navigate to Logbook in the left menu
    • View real-time API requests with Source IP, Timestamp, Endpoint, and Status
    • Filter by endpoint, IP, or error messages using the search box
    • The chart shows request volume over time to identify traffic patterns
  2. Check the Users page:

    • Navigate to Users to see the tracked user
    • Verify the user details and events are correctly recorded
  3. Verify event types:

    • Test each event type you plan to use
    • Confirm events appear in the correct user timeline

Risk rules & customization

tirreno is designed to be customized for your specific security needs. No CLA or pull request is required for local modifications.

The two main customization points are:

  1. Custom rules Create detection rules based on user behavior
  2. Suspicious pattern lists Adjust URL, user agent, and email pattern detection

Rule presets

tirreno includes pre-configured rule sets for common security scenarios. Presets provide a quick starting point—select one from the Rules page dropdown and click Apply.

PresetUse Case
defaultEmpty rules (start from scratch)
account_takeoverDetect compromised accounts via new devices, locations, password changes
credential_stuffingDetect automated login attempts and brute force attacks
content_spamDetect spam content and suspicious posting patterns
account_registrationProtect registration from fake accounts and bots
fraud_preventionGeneral fraud detection across multiple vectors
insider_threatDetect unusual employee behavior and data exfiltration
bot_detectionIdentify automated traffic and crawlers
dormant_accountMonitor reactivation of long-inactive accounts
multi_accountingDetect users with multiple accounts
promo_abuseDetect promotional code and offer abuse
api_protectionProtect APIs from abuse and scanning
high_risk_regionsFlag traffic from high-fraud geographic regions

Each preset assigns weights to specific rules. You can customize the weights after applying a preset.

Rule weights:

WeightValueEffect on Risk Score
Positive-20Decreases risk (trusted behavior)
None0Rule disabled
Medium10Moderate risk increase
High20Significant risk increase
Extreme70Major risk increase

Rule organization

Rules are organized by namespace (core vs custom) and category (prefix letter).

Namespaces:

NamespaceDirectoryDescription
\Tirreno\Rules\Coreassets/rules/core/Built-in rules (109 rules)
\Tirreno\Rules\Customassets/rules/custom/Your custom rules

Rule categories by prefix:

PrefixCategoryExample
AAccount takeoverA01–A08
BBehaviourB01–B26
CCountryC01–C16
DDeviceD01–D10
EEmailE01–E30
IIPI01–I12
PPhoneP01–P04
RReuse/BlacklistR01–R03
XCustom/ExtraX01, X02, ...

Custom rules must use the X prefix (e.g., X01.php, X02.php). Core rule prefixes (A–R) are reserved.

Built-in rules

tirreno includes standard detection rules organized by category:

Account takeover (A01-A08)

RuleNameDescription
A01Multiple login failUser failed to login multiple times in a short term
A02Login failed on new deviceUser failed to login with new device
A03New device and new countryUser logged in with new device from new location
A04New device and new subnetUser logged in with new device from new subnet
A05Password change on new deviceUser changed their password on new device
A06Password change in new countryUser changed their password in new country
A07Password change in new subnetUser changed their password in new subnet
A08Browser language changedUser accessed the account with new browser language

Behaviour (B01-B26)

RuleNameDescription
B01Multiple countriesIP addresses are located in diverse countries
B02User has changed a passwordThe user has changed their password
B03User has changed an emailThe user has changed their email
B04Multiple 5xx errorsUser made multiple requests which evoked internal server error
B05Multiple 4xx errorsUser made multiple requests which cannot be fulfilled
B06Potentially vulnerable URLUser made a request to suspicious URL
B07User's full name contains digitsFull name contains digits
B08Dormant account (30 days)Account has been inactive for 30 days
B09Dormant account (90 days)Account has been inactive for 90 days
B10Dormant account (1 year)Account has been inactive for a year
B11New account (1 day)Account has been created today
B12New account (1 week)Account has been created this week
B13New account (1 month)Account has been created this month
B14Aged account (>30 days)Account has been created over 30 days ago
B15Aged account (>90 days)Account has been created over 90 days ago
B16Aged account (>180 days)Account has been created over 180 days ago
B17Single countryIP addresses are located in a single country
B18HEAD requestHTTP request HEAD method is often used by bots
B19Night time requestsUser was active from midnight till 5 a.m.
B20Multiple countries in one sessionUser's country changed in less than 30 minutes
B21Multiple devices in one sessionUser's device changed in less than 30 minutes
B22Multiple IP addresses in one sessionUser's IP changed in less than 30 minutes
B23User's full name contains space or hyphenFull name contains space or hyphen
B24Empty refererUser made a request without a referer
B25Unauthorized requestUser made a successful request without authorization
B26Single event sessionsUser had sessions with only one event

Country (C01-C16)

RuleNameDescription
C01Nigeria IP addressIP address located in Nigeria
C02India IP addressIP address located in India
C03China IP addressIP address located in China
C04Brazil IP addressIP address located in Brazil
C05Pakistan IP addressIP address located in Pakistan
C06Indonesia IP addressIP address located in Indonesia
C07Venezuela IP addressIP address located in Venezuela
C08South Africa IP addressIP address located in South Africa
C09Philippines IP addressIP address located in Philippines
C10Romania IP addressIP address located in Romania
C11Russia IP addressIP address located in Russia
C12European IP addressIP address located in European Union
C13North America IP addressIP address located in Canada or USA
C14Australia IP addressIP address located in Australia
C15UAE IP addressIP address located in United Arab Emirates
C16Japan IP addressIP address located in Japan

Device (D01-D10)

RuleNameDescription
D01Device is unknownUser has manipulated device information
D02Device is LinuxLinux OS, increased risk of crawler bot
D03Device is botUser agent identified as a bot
D04Rare browser deviceUser operates device with uncommon browser
D05Rare OS deviceUser operates device with uncommon OS
D06Multiple devices per userUser accesses account using multiple devices
D07Several desktop devicesUser accesses account using different OS desktop devices
D08Two or more phone devicesUser accesses account using numerous phone devices
D09Old browserUser accesses account using an old browser version
D10Potentially vulnerable User-AgentUser made a request with suspicious User-Agent

Email (E01-E30)

RuleNameDescription
E01Invalid email formatInvalid email format
E02New domain and no breachesEmail belongs to recently created domain with no breach history
E03Suspicious words in emailEmail contains auto-generated mailbox patterns
E04Numeric email nameEmail username consists entirely of numbers
E05Special characters in emailEmail has unusually high number of special characters
E06Consecutive digits in emailEmail includes at least two consecutive digits
E07Long email usernameEmail username exceeds average length
E08Long domain nameEmail domain name is too long
E09Free email providerEmail belongs to free provider
E10The website is unavailableDomain's website seems to be inactive
E11Disposable emailDisposable email addresses are temporary
E12Free email and no breachesEmail belongs to free provider with no breach history
E13New domainDomain name was registered recently
E14No MX recordEmail's domain has no MX record
E15No breaches for emailEmail was not involved in any data breaches
E16Domain appears in spam listsEmail appears in spam lists
E17Free email and spamEmail appears in spam lists and is from free provider
E19Multiple emails changedUser has changed their email
E20Established domain (> 3 year old)Email belongs to domain registered at least 3 years ago
E21No vowels in emailEmail username does not contain any vowels
E22No consonants in emailEmail username does not contain any consonants
E23Educational domain (.edu)Email belongs to educational domain
E24Government domain (.gov)Email belongs to government domain
E25Military domain (.mil)Email belongs to military domain
E26iCloud mailboxEmail belongs to Apple domains (icloud.com, me.com, mac.com)
E27Email breachesEmail appears in data breaches
E28No digits in emailEmail address does not include digits
E29Old breach (>3 years)Earliest data breach appeared more than 3 years ago
E30Domain with average rankEmail domain has Tranco rank between 100,000 and 4,000,000

Note: E18 is reserved for future use.

IP (I01-I12)

RuleNameDescription
I01IP belongs to TORIP assigned to The Onion Router network
I02IP hosting domainHigher risk of crawler bot
I03IP appears in spam listUser may have exhibited unwanted activity before
I04Shared IPMultiple users detected on same IP address
I05IP belongs to commercial VPNUser tries to hide real location
I06IP belongs to datacenterUser is utilizing an ISP datacenter
I07IP belongs to Apple RelayIP belongs to iCloud Private Relay
I08IP belongs to StarlinkIP belongs to SpaceX satellite network
I09Numerous IPsUser accesses account with numerous IP addresses
I10Only residential IPsUser uses only residential IP addresses
I11Single networkIP addresses belong to one network
I12IP belongs to LANIP address belongs to local access network

Phone (P01-P04)

RuleNameDescription
P01Invalid phone formatUser provided incorrect phone number
P02Phone country mismatchPhone number country is not among user's login countries
P03Shared phone numberUser provided a phone number shared with another user
P04Valid phoneUser provided correct phone number

Reuse/blacklist (R01-R03)

RuleNameDescription
R01IP in blacklistThis IP address appears in the blacklist
R02Email in blacklistThis email address appears in the blacklist
R03Phone in blacklistThis phone number appears in the blacklist

Developing custom rules

Custom rules are placed in assets/rules/custom/ with filenames X01.php, X02.php, etc.

Each rule must:

  • Use namespace Tirreno\Rules\Custom
  • Extend \Tirreno\Assets\Rule
  • Define constants: NAME, DESCRIPTION, ATTRIBUTES
  • Implement defineCondition() method

Example rule

See assets/rules/custom/X03.example.php for a complete example:

<?php

namespace Tirreno\Rules\Custom;

class X03 extends \Tirreno\Assets\Rule {
    public const NAME = '1xx user name';
    public const DESCRIPTION = 'Username starts with digit 1.';
    public const ATTRIBUTES = [];

    protected function defineCondition() {
        return $this->rb->logicalAnd(
            $this->rb['extra_one_digit_userid']->equalTo(true),
        );
    }
}

Custom context

For rules that need custom data, create a Context class in assets/rules/custom/Context.php. See Context.example.php:

<?php

declare(strict_types=1);

namespace Tirreno\Rules\Custom;

class Context extends \Tirreno\Assets\Context {
    protected $DB_TABLE_NAME = 'event_account';
    protected $uniqueValues = false;

    public function expandContext(array &$extraData, array &$user): void {
        // Add custom attributes to $user array
        $user['extra_one_digit_userid'] = substr(($extraData['extra_userid'][0][0] ?? ' '), 0, 1) === '1';
    }

    protected function getDetails(array $accountIds, int $apiKey): array {
        [$params, $placeHolders] = $this->getRequestParams($accountIds, $apiKey);

        $query = (
            "SELECT
                event_account.id      AS id,
                event_account.userid  AS extra_userid
            FROM event_account
            WHERE event_account.id IN ({$placeHolders})
              AND event_account.key = :api_key"
        );

        return $this->execQuery($query, $params);
    }
}

Testing rules

  1. Refresh rules: After creating or modifying rules, go to the Rules page and click Refresh at the bottom of the page to apply your changes
  2. Test a rule: Select a rule and click the Play button (▷) to see how many users are triggered by the rule
  3. Match rate: The percentage shown indicates how many users from 1000 match the rule (e.g., "22%" means 22% of 1000 users trigger this rule)

Ruler operators reference

The rules engine uses ruler/ruler for condition evaluation. Available operators in defineCondition():

OperatorDescriptionExample
equalToExact match$this->rb['ea_total_country']->equalTo(1)
notEqualToNot equal$this->rb['eip_tor']->notEqualTo(true)
greaterThanGreater than$this->rb['ea_total_ip']->greaterThan(9)
greaterThanOrEqualToGreater or equal$this->rb['ea_days_since_last_visit']->greaterThanOrEqualTo(30)
lessThanLess than$this->rb['ea_days_since_account_creation']->lessThan(7)
lessThanOrEqualToLess or equal$this->rb['eup_device_count']->lessThanOrEqualTo(1)
stringContainsSubstring match$this->rb['le_email']->stringContains('test')
stringContainsInsensitiveCase-insensitive substring$this->rb['le_domain_part']->stringContainsInsensitive('mail')
startsWithPrefix match$this->rb['event_url_string']->startsWith('/api/')
endsWithSuffix match$this->rb['le_email']->endsWith('.edu')
sameAsVariable comparison$this->rb['lp_country_code']->sameAs($this->rb['eip_country_id'])

Logical operators:

// AND - all conditions must be true
$this->rb->logicalAnd(
    $this->rb['eip_tor']->equalTo(true),
    $this->rb['ea_days_since_account_creation']->lessThan(7)
);

// OR - at least one condition must be true
$this->rb->logicalOr(
    $this->rb['eip_vpn']->equalTo(true),
    $this->rb['eip_tor']->equalTo(true)
);

// NOT - negate a condition
$this->rb->logicalNot(
    $this->rb['le_has_no_data_breaches']->equalTo(true)
);

Rule context attributes

When writing custom rules, the following attributes are available in the defineCondition() method. Access them via $this->rb['attribute_name'].

Event attributes (event_)

From Event context:

AttributeTypeDescription
event_iparrayIP IDs per event
event_url_stringarrayURLs per event
event_empty_refererarrayEmpty referer status per event
event_devicearrayDevice IDs per event
event_typearrayEvent types
event_http_codearrayHTTP response codes
event_http_methodarrayHTTP methods
event_device_createdarrayDevice creation timestamps
event_device_lastseenarrayDevice last seen timestamps

Derived event attributes:

AttributeTypeDescription
event_email_changedboolUser changed email in recent events
event_password_changedboolUser changed password in recent events
event_http_method_headboolHEAD request detected
event_empty_refererboolRequest had empty referer
event_multiple_5xx_httpintCount of 5xx server errors
event_multiple_4xx_httpintCount of 4xx client errors
event_2xx_httpboolSuccessful requests exist
event_vulnerable_urlboolURL matches suspicious patterns

Account attributes (ea_)

Raw account data from User context:

AttributeTypeDescription
ea_useridstringUser identifier
ea_createdstringAccount creation timestamp
ea_lastseenstringLast activity timestamp
ea_total_visitintTotal visits
ea_total_countryintTotal countries
ea_total_ipintTotal IP addresses
ea_total_deviceintTotal devices
ea_firstnamestringFirst name
ea_lastnamestringLast name

Derived account attributes:

AttributeTypeDescription
ea_days_since_account_creationintDays since account was created (-1 if unknown)
ea_days_since_last_visitintDays since user's last activity (-1 if unknown)
ea_fullname_has_numbersboolFull name contains digits
ea_fullname_has_spaces_hyphensboolFull name contains spaces or hyphens

IP attributes (eip_)

From Ip context:

AttributeTypeDescription
eip_cidr_countarrayCount of IPs per CIDR
eip_country_countarrayCount of IPs per country
eip_country_idarrayCountry IDs
eip_data_centerboolIP belongs to datacenter
eip_torboolIP belongs to TOR network
eip_vpnboolIP belongs to commercial VPN
eip_starlinkboolIP belongs to Starlink
eip_blocklistboolIP appears in spam/blocklist
eip_has_fraudboolFraud detected for IP
eip_lanboolIP belongs to LAN
eip_sharedintNumber of users sharing this IP
eip_domains_count_lenintNumber of domains on IP
eip_unique_cidrsintNumber of unique network ranges
eip_only_residentialboolAll IPs are residential (derived)

Device attributes (eup_)

From Device context:

AttributeTypeDescription
eup_devicearrayDevice types (desktop, smartphone, tablet, etc.)
eup_browser_namearrayBrowser names
eup_browser_versionarrayBrowser versions
eup_os_namearrayOperating system names
eup_langarrayBrowser languages
eup_uaarrayRaw user agent strings

Derived device attributes:

AttributeTypeDescription
eup_device_countintNumber of devices used
eup_has_rare_browserboolUser has uncommon browser
eup_has_rare_osboolUser has uncommon OS
eup_vulnerable_uaboolUser-Agent matches suspicious patterns

Session attributes (event_session_)

From Session context:

AttributeTypeDescription
event_session_single_eventboolSession had only one event
event_session_multiple_countryboolCountry changed within 30 min
event_session_multiple_ipboolIP changed within 30 min
event_session_multiple_deviceboolDevice changed within 30 min
event_session_night_timeboolActivity between midnight and 5 AM

Email attributes (Platform Edition only)

Last Email Attributes (le_):

AttributeTypeDescription
le_emailstringEmail address
le_local_partstringEmail username (before @)
le_domain_partstringEmail domain (after @)
le_blockemailsboolEmail is in blocklist
le_data_breachboolKnown data breaches
le_checkedboolEmail has been verified
le_fraud_detectedboolFraud detected for email
le_alert_listboolEmail on alert list

Derived last email attributes:

AttributeTypeDescription
le_existsboolEmail address exists
le_is_invalidboolEmail format is invalid
le_has_suspicious_strboolEmail contains suspicious patterns
le_has_numeric_only_local_partboolEmail username is all numbers
le_email_has_consec_s_charsboolEmail has consecutive special characters
le_email_has_consec_numsboolEmail has consecutive digits
le_email_has_no_digitsboolEmail has no digits
le_email_has_vowelsboolEmail username contains vowels
le_email_has_consonantsboolEmail username contains consonants
le_with_long_local_part_lengthboolEmail username exceeds max length
le_with_long_domain_lengthboolEmail domain exceeds max length
le_email_in_blockemailsboolEmail is in blocklist
le_has_no_data_breachesboolNo known data breaches
le_appears_on_alert_listboolEmail on alert list
le_local_part_lenintLength of email username

Email Attributes (ee_):

AttributeTypeDescription
ee_emailarrayAll email addresses for user
ee_earliest_breacharrayEarliest breach dates per email
ee_days_since_first_breachintDays since earliest known breach (-1 if none)

Domain attributes (Platform Edition only)

Last Domain Attributes (ld_):

AttributeTypeDescription
ld_disposable_domainsboolDomain is disposable email provider
ld_free_email_providerboolDomain is free email provider
ld_blockdomainsboolDomain is in blocklist
ld_mx_recordboolDomain has MX record
ld_disabledboolDomain website is disabled
ld_creation_datestringDomain creation date
ld_tranco_rankintTranco ranking (-1 if not ranked)

Derived last domain attributes:

AttributeTypeDescription
ld_is_disposableboolDomain is disposable email provider
ld_days_since_domain_creationintDays since domain registration
ld_domain_free_email_providerboolDomain is free email provider
ld_from_blockdomainsboolDomain is in blocklist
ld_domain_without_mx_recordboolDomain has no MX record
ld_website_is_disabledboolDomain website is disabled

Phone attributes (Platform Edition only)

From Phone context (ep_):

AttributeTypeDescription
ep_phone_numberarrayPhone numbers
ep_sharedarrayShared status per phone
ep_typearrayPhone types

Last phone from User context (lp_):

AttributeTypeDescription
lp_phone_numberstringLast phone number
lp_country_codestringPhone country code
lp_invalidboolPhone number is invalid
lp_fraud_detectedboolFraud detected for phone
lp_alert_listboolPhone on alert list

Derived phone attributes:

AttributeTypeDescription
lp_invalid_phoneboolPhone number is invalid
ep_shared_phoneboolPhone is shared with other users

Suspicious pattern lists

tirreno maintains lists of suspicious patterns in assets/lists/:

FilePurpose
url.phpURL attack patterns (SQL injection, path traversal, etc.)
user-agent.phpSuspicious user agent strings
email.phpSuspicious email patterns
file-extensions.phpFile extension categories

Each file returns a PHP array:

<?php
return [
    '.env',
    '.git',
    '/wp-admin',
    'phpmyadmin',
    '<script>',
    // ...
];

To add patterns:

  1. Open the appropriate file in assets/lists/
  2. Add your pattern string to the array
  3. Patterns are case-sensitive substring matches

Example patterns by type:

ListExample Patterns
url.php'.env', '../', '/wp-admin', 'phpmyadmin', '<script>'
user-agent.phpBot signatures, scanner identifiers, SQL injection attempts
email.php'spam', 'test', 'dummy', '123', '000'

Contributing

This section is for developers who want to contribute code to the tirreno project. If you only want to customize tirreno for your own use (custom rules, pattern lists), see the Risk rules & customization section above.

Notice: Submissions using generative AI will be rejected. Submissions from AI chatbots will result in the account being banned.

Source code

The source code is maintained at: https://github.com/tirrenotechnologies/tirreno

Before you start

Most issues in the tirreno issue tracker are ideas and bugs that the team would like to implement or solve. However, this is not always the case — the team may no longer be interested in some issues even though they remain open.

Before you spend time working on a bug or feature (and risk it not being merged), it is highly recommended that you first leave a comment on the issue explaining that you are interested in contributing. In your comment, also explain how you plan to solve the bug or implement the new feature, and ask for a quick validation of your approach.

This gives the tirreno team the opportunity to review your proposal, confirm whether they want to see it added, and provide early guidance. The team will reply in the issue, and once they confirm, you can confidently work towards opening a Pull Request.

If no existing issue matches your idea, create a new issue first and wait for team feedback before starting development.

Contributor license agreement (CLA)

Before your contributions can be accepted, you must sign the tirreno Contributor License Agreement (CLA). All contributed code is dual-licensed: AGPL-3.0 for open source use and a separate enterprise license for commercial use.

Git workflow

  1. Fork the repository on GitHub
  2. Clone your fork: git clone https://github.com/YOUR_USERNAME/tirreno.git
  3. Create a branch: git checkout -b feature/your-feature
  4. Make changes following coding standards
  5. Commit, push, and open a Pull Request

Local development setup

Prerequisites

  • PHP 8.0 to 8.3 with extensions: PDO_PGSQL, cURL, mbstring
  • PostgreSQL 12 or greater
  • Apache with mod_rewrite
  • Composer
  • Git

Local setup

# 1. Fork and clone
git clone https://github.com/YOUR_USERNAME/tirreno.git
cd tirreno

# 2. Install dependencies
composer install

# 3. Create PostgreSQL database
createdb tirreno_dev

# 4. Configure database
# Edit config/ files with your database credentials

# 5. Run web installer
# Point Apache to project root, visit: http://localhost/install/

# 6. Delete install directory (important!)
rm -rf install/

# 7. Setup cron job
crontab -e
# Add: */10 * * * * /usr/bin/php /absolute/path/to/tirreno/index.php /cron

# 8. Create admin account at /signup/

Docker setup

One line:

curl -sL tirreno.com/t.yml | docker compose -f - up -d

Code quality tools

tirreno uses the following tools for code quality:

  • PHP_CodeSniffer (phpcs.xml) for PHP style enforcement
  • PHPStan for static analysis
  • ESLint (eslint.config.js) for JavaScript
# PHP CodeSniffer - check style
./vendor/bin/phpcs --standard=phpcs.xml app/

# PHP CodeSniffer - auto-fix
./vendor/bin/phpcbf --standard=phpcs.xml app/

# PHPStan - static analysis
./vendor/bin/phpstan analyse

# ESLint - JavaScript
npx eslint ui/js/
npx eslint ui/js/ --fix

PHP coding standards

Class structure

Follow the tirreno Model pattern:

<?php
declare(strict_types=1);
namespace Tirreno\Models;

class Device extends \Tirreno\Models\BaseSql {
    protected $DB_TABLE_NAME = 'event_device';

    public function getFullDeviceInfoById(int $deviceId, int $apiKey): array {
        // ...
    }
}

Use fully-qualified class names and type declarations for all parameters and return types.

Naming conventions

ElementConventionExample
Classes/NamespacesPascalCaseDevice, BaseSql
Methods/VariablescamelCasegetDeviceInfo(), $apiKey
ConstantsUPPER_SNAKE_CASEDB_TABLE_NAME
Tables/Columnssnake_caseevent_device, api_key
Query params:snake_case:api_key, :device_id

Query string style

Use parentheses for multiline SQL queries:

$query = (
    'SELECT id, lang, created
    FROM event_device
    WHERE key = :api_key'
);

SQL security

Always use named PDO parameters:

// Good
$params = [':api_key' => $apiKey, ':device_id' => $subjectId];
$query = ('SELECT id FROM event_device WHERE id = :device_id AND key = :api_key');
$results = $this->execQuery($query, $params);

// Bad - never concatenate user input
$query = "SELECT * FROM event_device WHERE id = $deviceId";

Database best practices

Extend \Models\BaseSql, use execQuery(). Never raw PDO.

XSS prevention

Templates auto-escape with {{ @var }}. Use htmlspecialchars() at output time in PHP:

echo htmlspecialchars($userInput, ENT_QUOTES, 'UTF-8');

Template syntax

tirreno uses the Fat-Free Framework's template engine with includes, variables, and inline PHP:

<include href="templates/parts/headerAdmin.html" />
<div id="wrap">
    <include href="templates/parts/panel/eventPanel.html" />
    <include href="templates/parts/panel/devicePanel.html" />
    <include href="templates/parts/leftMenu.html" />
    <div class="main">
        <include href="templates/parts/forms/globalSearchForm.html" />
        <include href="templates/parts/systemNotification.html" />
        <include href="templates/parts/notification.html" />

        {~
            $country = ['iso' => $IP['country_iso']];
            $subtitle = array();
            if(isset($IP['name']) && !empty($IP['name'])) {
                $subtitle[] = $IP['name'];
            }
            $subtitle = join(', ', $subtitle);
        ~}

        <include href="templates/parts/infoHeader.html" with="title={{@IP.ip}}, country={{@country}}, id={{@IP.id}}"/>
        <include href="templates/parts/widgets/ip.html" />
        <include href="templates/parts/tables/users.html" />
        <include href="templates/parts/tables/events.html" with="showChart=1"/>
    </div>
</div>
<include href="templates/parts/footerAdmin.html" />

Template conventions:

SyntaxPurposeExample
{{ @var }}Output escaped variable{{ @IP.ip }}
{{ @var | raw }}Output unescaped (careful!){{ @htmlContent | raw }}
{{ @arr.key }}Access array element{{ @IP.country_iso }}
{~ ... ~}Inline PHP code block{~ $x = 1 + 2; ~}
<include href="..." />Include template file<include href="templates/parts/header.html" />
<include ... with="..." />Include with parameters<include href="..." with="title={{@IP.ip}}, id={{@IP.id}}"/>
{** ... **}Template comment (not rendered){**<include href="..." />**}

Template directory structure:

ui/templates/
├── layout.html             # Base layout
├── pages/                  # Page templates
│   ├── admin/              # Admin page templates
│   │   ├── events.html
│   │   ├── ip.html
│   │   ├── users.html
│   │   └── ...
│   ├── login.html
│   ├── signup.html
│   └── ...
├── parts/                  # Reusable components
│   ├── headerAdmin.html    # Common header
│   ├── footerAdmin.html    # Common footer
│   ├── leftMenu.html       # Navigation menu
│   ├── notification.html   # Alert messages
│   ├── forms/              # Form components
│   ├── panel/              # Side panels
│   ├── tables/             # Data tables
│   ├── widgets/            # Dashboard widgets
│   └── choices/            # Filter dropdowns
└── snippets/               # Code snippets
    ├── php.html
    ├── python.html
    └── nodejs.html

Key patterns:

  • Use <include> for reusable components (DRY principle)
  • Pass data with with="param1={{@var1}}, param2={{@var2}}"
  • Use {~ ... ~} for template logic (preprocessing data before display)
  • Comment out unused includes with {** ... **}
  • Access nested array data with dot notation: @IP.country_iso

Internationalization (i18n)

tirreno uses the framework's built-in internationalization support. Language strings are stored in dictionary files under app/Dictionary/.

Using translations in templates:

<h1>{{ @DICT.dashboard_title }}</h1>
<button>{{ @DICT.save_button }}</button>

Using translations in PHP:

$f3 = \Base::instance();

// Get translated string
$message = $f3->get('DICT.welcome_message');

// With variables
$greeting = sprintf($f3->get('DICT.hello_user'), $userName);

Best practices:

  • Never hardcode user-visible strings, use dictionary keys
  • Keep dictionary keys descriptive: dashboard_title, not dt1
  • Group related strings with prefixes: error_invalid_email, error_login_failed
  • Don't concatenate translated strings, word order varies by language

JavaScript coding standards

Follow the ESLint configuration in eslint.config.js:

// Use const/let, not var
const API_ENDPOINT = '/sensor/';
let eventCount = 0;

// Use arrow functions
const trackEvent = async (userId, eventType) => {
    const response = await fetch(API_ENDPOINT, {
        method: 'POST',
        body: new URLSearchParams({ userName: userId, eventType }),
    });
    return response.ok;
};

// Use template literals
const message = `User ${userId} logged in at ${timestamp}`;

Page architecture

tirreno uses ES6 modules with a class-based page structure:

import {BasePage} from './Base.js';

import {DatesFilter} from '../parts/DatesFilter.js?v=2';
import {SearchFilter} from '../parts/SearchFilter.js?v=2';
import {IpTypeFilter} from '../parts/choices/IpTypeFilter.js?v=2';
import {IpsChart} from '../parts/chart/Ips.js?v=2';
import {IpsGrid} from '../parts/grid/Ips.js?v=2';

export class IpsPage extends BasePage {

    constructor() {
        super('ips');
        this.initUi();
    }

    initUi() {
        const datesFilter  = new DatesFilter();
        const searchFilter = new SearchFilter();
        const ipTypeFilter = new IpTypeFilter();

        this.filters = {
            dateRange:      datesFilter,
            searchValue:    searchFilter,
            ipTypeIds:      ipTypeFilter,
        };

        const gridParams = {
            url:        `${window.app_base}/admin/loadIps`,
            tileId:     'totalIps',
            tableId:    'ips-table',

            dateRangeGrid:      true,
            calculateTotals:    true,
            totals: {
                type: 'ip',
                columns: ['total_visit'],
            },

            isSortable:         true,
            orderByLastseen:    false,

            choicesFilterEvents: [ipTypeFilter.getEventType()],
            getParams: this.getParamsSection,
        };

        const chartParams = this.getChartParams(datesFilter, searchFilter);

        new IpsChart(chartParams);
        new IpsGrid(gridParams);
    }
}

JavaScript conventions:

PatternDescriptionExample
ES6 modulesUse import/exportimport {BasePage} from './Base.js';
Class inheritancePages extend BasePageclass IpsPage extends BasePage
Version cache-bustingAppend ?v=N to imports'../parts/DatesFilter.js?v=2'
Constructor patternCall super(), then initUi()super('ips'); this.initUi();
Filters objectStore filter instancesthis.filters = { dateRange, searchValue }
Global app baseUse window.app_base for URLs`${window.app_base}/admin/loadIps`

JavaScript directory structure:

ui/js/
├── endpoints/                  # Page entry points
│   ├── admin_ips.js
│   ├── admin_events.js
│   └── ...
├── pages/                      # Page controllers
│   ├── Base.js                 # Base page class
│   ├── Ips.js                  # IPs page (IpsPage)
│   ├── Events.js               # Events page
│   └── ...
├── parts/                      # Reusable components
│   ├── DatesFilter.js          # Date range filter
│   ├── SearchFilter.js         # Search input filter
│   ├── DataRenderers.js        # Column rendering functions
│   ├── choices/                # Dropdown filters (Choices.js)
│   │   └── IpTypeFilter.js
│   ├── chart/                  # Chart components (uPlot)
│   │   └── Ips.js
│   ├── grid/                   # Data grid components (DataTables)
│   │   └── Ips.js
│   ├── panel/                  # Detail panels
│   └── utils/                  # Utility modules
│       ├── Constants.js
│       ├── String.js
│       └── Date.js
└── vendor/                     # Third-party libraries

File formatting

  • Indentation: 4 spaces (no tabs), check .editorconfig if present
  • Line endings: Unix (LF)
  • File encoding: UTF-8
  • Trailing newline: Yes

Code comments

tirreno uses a minimal documentation style. Write self-documenting code with descriptive names and type declarations. Add comments only to explain "why", not "what":

// Good - explains why
// Skip devices that haven't been updated since last sync
if ($device->lastseen < $lastSync) {
    continue;
}

// Bad - states the obvious
// Check if lastseen is less than lastSync
if ($device->lastseen < $lastSync) {
    continue;
}

Commit messages

Write good commit messages. Follow these guidelines:

Format: <type>: <subject>. Types: Add, Fix, Update, Remove, Refactor, Docs

Add: user session timeout configuration

Allow admins to configure timeout. Default 30 min.
Closes #123

Line endings

All text files should use Unix-style line endings (LF, not CRLF). Windows developers should configure Git: git config --global core.autocrlf input

Testing

Before submitting a pull request:

  1. Test your changes on Chrome and Firefox
  2. Run code quality checks: phpcs, phpstan, eslint
  3. Verify database changes work with PostgreSQL 12+

Resources

ResourceURL
Live Demoplay.tirreno.com (admin/tirreno)
Documentationdocs.tirreno.com
Resource centertirreno.com/bat
Administration guidegithub.com/tirrenotechnologies/ADMIN.md
GitHubgithub.com/tirrenotechnologies/tirreno
GitLab Mirrorgitlab.com/tirreno/tirreno
Docker Hubhub.docker.com/r/tirreno/tirreno
Docker Repogithub.com/tirrenotechnologies/docker
Packagistpackagist.org/packages/tirreno/tirreno
PHP Trackergithub.com/tirrenotechnologies/tirreno-php-tracker
Python Trackergithub.com/tirrenotechnologies/tirreno-python-tracker
Node.js Trackergithub.com/tirrenotechnologies/tirreno-nodejs-tracker
Community Chatchat.tirreno.com
Support Emailping@tirreno.com
Security Emailsecurity@tirreno.com

Found a mistake?

If you have found a mistake in the documentation, no matter how large or small, please let us know by creating a new issue in the tirreno repository.


License

tirreno and this documentation are licensed under the GNU Affero General Public License v3 (AGPL-3.0).

The name "tirreno" is a registered trademark of tirreno technologies sàrl.


tirreno Copyright (C) 2026 tirreno technologies sàrl, Vaud, Switzerland.

't'