Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions external-import/stairwell/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
**/__pycache__
**/*.pyc
**/.pytest_cache
**/.venv
src/tests
src/config.yml
36 changes: 36 additions & 0 deletions external-import/stairwell/.env.test.sample
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Source this file before running the connector locally:
# cp .env.test.sample .env.test
# # edit values
# set -a; source .env.test; set +a
# python3 main.py
#
# Or with explicit exports (if not using set -a):
# source .env.test

# --- OpenCTI ---
export OPENCTI_URL="http://localhost:8080"
export OPENCTI_TOKEN="REPLACE_WITH_ADMIN_TOKEN_FROM_OPENCTI_DOTENV"

# --- Connector ---
export CONNECTOR_ID="REPLACE_WITH_UUIDV4" # generate with: uuidgen | tr A-Z a-z
export CONNECTOR_TYPE="EXTERNAL_IMPORT"
export CONNECTOR_NAME="Stairwell Import (manual test)"
export CONNECTOR_SCOPE="stairwell"
export CONNECTOR_LOG_LEVEL="info"
export CONNECTOR_DURATION_PERIOD="PT1H" # ISO-8601 schedule interval (long, so the loop won't fire twice during a manual test)

# --- Stairwell ---
export STAIRWELL_API_TOKEN="REPLACE_WITH_STAIRWELL_API_TOKEN"
export STAIRWELL_API_BASE_URL="https://app.stairwell.com"
export STAIRWELL_ORGANIZATION_ID="" # optional, for rate-limit boost
export STAIRWELL_USER_ID="" # optional, for rate-limit boost

# --- Import behavior (small values for fast smoke testing) ---
export STAIRWELL_IMPORT_TLP="green"
export STAIRWELL_IMPORT_FIRST_RUN_WINDOW="P7D" # backfill window on first run
export STAIRWELL_IMPORT_MAX_INDICATORS=5 # cap for manual testing; raise for real use
export STAIRWELL_IMPORT_PAGE_SIZE=20
export STAIRWELL_IMPORT_INDICATOR_VALIDITY_DAYS=90
export STAIRWELL_IMPORT_MIN_BUCKET="HIGH" # LOW | MEDIUM | HIGH | VERY_HIGH
export STAIRWELL_IMPORT_SCOPE="environment" # reserved / not yet effective
export STAIRWELL_IMPORT_WRAPPER="grouping" # grouping | report
19 changes: 19 additions & 0 deletions external-import/stairwell/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
FROM python:3.12-alpine
ENV CONNECTOR_TYPE=EXTERNAL_IMPORT

# Copy the connector
COPY src /opt/opencti-connector-stairwell-import

# Install Python modules
# hadolint ignore=DL3003
RUN apk update && apk upgrade && \
apk --no-cache add git build-base libmagic libffi-dev libxml2-dev libxslt-dev

RUN cd /opt/opencti-connector-stairwell-import && \
pip3 install --no-cache-dir -r requirements.txt && \
apk del git build-base

# Expose and entrypoint
COPY entrypoint.sh /
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
95 changes: 95 additions & 0 deletions external-import/stairwell/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# OpenCTI Stairwell Connector (External Import)

External-import connector that periodically pulls high-confidence Stairwell
indicators (hashes, domains, IPs, URLs) and ingests them into OpenCTI as
Indicators with related observables, wrapped in a Grouping or Report.

## Requirements

- OpenCTI Platform >= 6.8.12
- A Stairwell API token (Bearer auth)

## Configuration

Configuration is loaded via the `connectors-sdk` settings layer: from
`src/config.yml` if present, otherwise from environment variables.
Environment variables override the file.

### OpenCTI

| Parameter | config.yml | Env var | Required | Default | Description |
|---|---|---|---|---|---|
| OpenCTI URL | `opencti.url` | `OPENCTI_URL` | yes | — | OpenCTI platform URL |
| OpenCTI token | `opencti.token` | `OPENCTI_TOKEN` | yes | — | OpenCTI admin token |

### Connector

| Parameter | config.yml | Env var | Required | Default | Description |
|---|---|---|---|---|---|
| Connector ID | `connector.id` | `CONNECTOR_ID` | yes | — | UUIDv4 unique to this instance |
| Connector type | `connector.type` | `CONNECTOR_TYPE` | yes | `EXTERNAL_IMPORT` | Must be `EXTERNAL_IMPORT` |
| Connector name | `connector.name` | `CONNECTOR_NAME` | yes | `Stairwell Import` | Display name in OpenCTI |
| Connector scope | `connector.scope` | `CONNECTOR_SCOPE` | yes | `stairwell` | Free-form import scope tag |
| Log level | `connector.log_level` | `CONNECTOR_LOG_LEVEL` | no | `error` | `debug`, `info`, `warn`, `error` |
| Duration period | `connector.duration_period` | `CONNECTOR_DURATION_PERIOD` | no | `P1D` | ISO 8601 interval between runs (scheduling) |

### Stairwell

| Parameter | config.yml | Env var | Required | Default | Description |
|---|---|---|---|---|---|
| API token | `stairwell.api_token` | `STAIRWELL_API_TOKEN` | yes | — | Stairwell API token |
| API base URL | `stairwell.api_base_url` | `STAIRWELL_API_BASE_URL` | no | `https://app.stairwell.com` | Override for staging |
| Organization ID | `stairwell.organization_id` | `STAIRWELL_ORGANIZATION_ID` | no | — | Adds rate-limit header |
| User ID | `stairwell.user_id` | `STAIRWELL_USER_ID` | no | — | Adds rate-limit header |
| Import TLP | `stairwell.import_tlp` | `STAIRWELL_IMPORT_TLP` | no | `green` | `clear`, `green`, `amber`, `amber+strict`, `red` |
| First-run window | `stairwell.import_first_run_window` | `STAIRWELL_IMPORT_FIRST_RUN_WINDOW` | no | `P1D` | ISO 8601 duration to backfill on first run |
| Max indicators | `stairwell.import_max_indicators` | `STAIRWELL_IMPORT_MAX_INDICATORS` | no | `1000` | Cap per run |
| Page size | `stairwell.import_page_size` | `STAIRWELL_IMPORT_PAGE_SIZE` | no | `100` | API page size |
| Indicator validity | `stairwell.import_indicator_validity_days` | `STAIRWELL_IMPORT_INDICATOR_VALIDITY_DAYS` | no | `90` | `valid_until` offset |
| Min bucket | `stairwell.import_min_bucket` | `STAIRWELL_IMPORT_MIN_BUCKET` | no | `HIGH` | `LOW`, `MEDIUM`, `HIGH`, `VERY_HIGH` |
| Import scope | `stairwell.import_scope` | `STAIRWELL_IMPORT_SCOPE` | no | `environment` | Reserved for forward-compat — accepted but **not yet effective**; every run currently queries the full corpus |
| Wrapper | `stairwell.import_wrapper` | `STAIRWELL_IMPORT_WRAPPER` | no | `grouping` | `grouping` or `report` |

## Behavior

- **Periodic poll.** Scheduling is owned by the OpenCTI connector helper
(`schedule_process`) on the `CONNECTOR_DURATION_PERIOD` interval; each run
opens a work item so it appears in the OpenCTI work log. The first-run
backfill window prevents re-importing the full history.
- **Filtering.** A CEL expression pushes the malicious flag and the time cutoff
to Stairwell server-side. The minimum-bucket threshold is applied client-side
after each page is fetched, because the API rejects `in`-operator bucket
queries (returns HTTP 500).
- **Per-run wrapper.** All ingested objects are grouped under a single
Grouping (default) or Report SDO so reviewers can see exactly what landed in
one batch.
- **Indicator validity.** Each Indicator's `valid_from` is derived from the
Stairwell first-seen time (falling back to the run start time), and
`valid_until` = `valid_from + import_indicator_validity_days`.

## Installation

### Via Docker

```bash
docker build -t opencti/connector-stairwell-import:latest .
```

Then add the service from `docker-compose.yml` to your OpenCTI deployment.

### Local development

```bash
cd src
pip install -r requirements.txt
cp config.yml.sample config.yml # edit values
python3 main.py
```

## Tests

```bash
cd src
pip install -r requirements.txt -r tests/test-requirements.txt
PYTHONPATH=. pytest tests/
```
27 changes: 27 additions & 0 deletions external-import/stairwell/__metadata__/connector_manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"title": "Stairwell Import",
"slug": "stairwell-import",
"description": "The Stairwell external-import connector periodically pulls high-confidence malicious indicators from Stairwell's global file corpus into OpenCTI. On a configurable schedule it queries Stairwell MalEval verdicts for newly-seen malicious files and emits STIX Indicators together with their related observables (file hashes, IP addresses, domain names, and URLs), wrapped in a Grouping or Report so every run is auditable. Filtering by MalEval probability bucket, time window, and environment/global scope is configurable, enabling proactive threat hunting against fresh, vendor-attributed intelligence.",
"short_description": "Periodically imports high-confidence malicious file, IP, domain, and URL indicators from Stairwell's global corpus (MalEval verdicts) into OpenCTI for proactive threat hunting.",
"logo": "external-import/stairwell/__metadata__/logo.png",
"use_cases": [
"Detection & Response Enablement",
"Adversary & Campaign Insights"
],
"solution_categories": [
"Threat Intelligence Feed"
],
"license_type": "Commercial",
"contact": "support@stairwell.com",
"verified": true,
"last_verified_date": null,
"playbook_supported": false,
"max_confidence_level": 80,
"support_version": ">=6.8.12",
"subscription_link": "https://stairwell.com",
"source_code": "https://github.com/OpenCTI-Platform/connectors/tree/master/external-import/stairwell",
"manager_supported": false,
"container_version": "rolling",
"container_image": "opencti/connector-stairwell-import",
"container_type": "EXTERNAL_IMPORT"
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
25 changes: 25 additions & 0 deletions external-import/stairwell/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
services:
connector-stairwell-import:
image: opencti/connector-stairwell-import:rolling
environment:
- OPENCTI_URL=http://opencti:8080
- OPENCTI_TOKEN=${OPENCTI_ADMIN_TOKEN}
- CONNECTOR_ID=${CONNECTOR_STAIRWELL_IMPORT_ID}
- CONNECTOR_TYPE=EXTERNAL_IMPORT
- CONNECTOR_NAME=Stairwell Import
- CONNECTOR_SCOPE=stairwell
- CONNECTOR_LOG_LEVEL=error
- CONNECTOR_DURATION_PERIOD=P1D
- STAIRWELL_API_TOKEN=${STAIRWELL_API_TOKEN}
- STAIRWELL_API_BASE_URL=https://app.stairwell.com
- STAIRWELL_ORGANIZATION_ID=${STAIRWELL_ORGANIZATION_ID:-}
- STAIRWELL_USER_ID=${STAIRWELL_USER_ID:-}
- STAIRWELL_IMPORT_TLP=green
- STAIRWELL_IMPORT_FIRST_RUN_WINDOW=P1D
- STAIRWELL_IMPORT_MAX_INDICATORS=1000
- STAIRWELL_IMPORT_PAGE_SIZE=100
- STAIRWELL_IMPORT_INDICATOR_VALIDITY_DAYS=90
- STAIRWELL_IMPORT_MIN_BUCKET=HIGH
- STAIRWELL_IMPORT_SCOPE=environment
- STAIRWELL_IMPORT_WRAPPER=grouping
restart: always
7 changes: 7 additions & 0 deletions external-import/stairwell/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#!/bin/sh

# Go to the right directory
cd /opt/opencti-connector-stairwell-import

# Launch the connector
python3 main.py
25 changes: 25 additions & 0 deletions external-import/stairwell/src/config.yml.sample
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
opencti:
url: 'http://localhost:8080'
token: 'ChangeMe'

connector:
type: 'EXTERNAL_IMPORT'
id: 'ChangeMe'
name: 'Stairwell Import' # optional (default: 'Stairwell Import')
scope: 'stairwell'
log_level: 'error' # optional (default: 'error')
duration_period: 'P1D' # optional, ISO-8601 interval between two runs (default: 'P1D')

stairwell:
api_token: 'ChangeMe'
api_base_url: 'https://app.stairwell.com' # optional
organization_id: '' # optional, adds a rate-limit header
user_id: '' # optional, adds a rate-limit header
import_tlp: 'green' # optional, one of: clear, white, green, amber, amber+strict, red (default: 'green')
import_first_run_window: 'P1D' # optional, ISO-8601 backfill window on first run (default: 'P1D')
import_max_indicators: 1000 # optional (default: 1000)
import_page_size: 100 # optional (default: 100)
import_indicator_validity_days: 90 # optional (default: 90)
import_min_bucket: 'HIGH' # optional, one of: LOW, MEDIUM, HIGH, VERY_HIGH (default: 'HIGH')
import_scope: 'environment' # optional, reserved/not yet effective (default: 'environment')
import_wrapper: 'grouping' # optional, 'grouping' or 'report' (default: 'grouping')
Empty file.
68 changes: 68 additions & 0 deletions external-import/stairwell/src/connector/connector.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
from __future__ import annotations

from pycti import OpenCTIConnectorHelper

from connector.import_filter import parse_iso_duration
from connector.import_runner import ImportRunner
from connector.settings import ConnectorSettings
from connector.stairwell import StairwellClient


class StairwellImportConnector:
"""External-import connector: periodically pulls Stairwell MalEval indicators.

Scheduling and work registration are owned by the OpenCTI connector helper:
`run()` registers `process_message` with `schedule_process`, and each run
opens a work item (`initiate_work` / `to_processed`) so the ingestion shows
up in the OpenCTI work log.
"""

def __init__(
self, config: ConnectorSettings, helper: OpenCTIConnectorHelper
) -> None:
self.config = config
self.helper = helper

s = config.stairwell
self.client = StairwellClient(
api_token=s.api_token,
base_url=str(s.api_base_url),
organization_id=s.organization_id or None,
user_id=s.user_id or None,
)
self.runner = ImportRunner(
helper=helper,
client=self.client,
first_run_window=parse_iso_duration(s.import_first_run_window),
max_indicators=s.import_max_indicators,
page_size=s.import_page_size,
min_bucket=s.import_min_bucket,
scope_environment=(s.import_scope.lower() == "environment"),
wrapper=s.import_wrapper,
tlp=s.import_tlp,
indicator_validity_days=s.import_indicator_validity_days,
)

def process_message(self) -> None:
friendly_name = "Stairwell import"
work_id = self.helper.api.work.initiate_work(
self.helper.connect_id, friendly_name
)
try:
message = self.runner.run(work_id=work_id)
except Exception as err: # noqa: BLE001
self.helper.connector_logger.error(
"[CONNECTOR] Stairwell import run failed",
{"error": str(err)},
)
message = f"Run failed: {err}"
self.helper.api.work.to_processed(work_id, message)
self.helper.connector_logger.info(
"[CONNECTOR] Stairwell import run finished", {"message": message}
)

def run(self) -> None:
self.helper.schedule_process(
message_callback=self.process_message,
duration_period=self.config.connector.duration_period.total_seconds(),
)
Loading