Install any skill in seconds. Free to start, no credit card required.
Get Started Free →OpenCTI is an open-source platform for managing cyber threat intelligence knowledge, built on STIX 2.1 as its native data model. This skill covers building an automated IOC enrichment pipeline using O
.claude/skills/building-ioc-enrichment-pipeline-with-opencti/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | — | — |
| case-07 | ✓→✓ | = Same ✓ | — | — |
| case-18 | ✗→✗ | = Same ✗ | — | — |
| case-19 | ✗→✗ | = Same ✗ | — | — |
| case-17 | ✗→✗ | = Same ✗ | — | — |
OpenCTI is an open-source platform for managing cyber threat intelligence knowledge, built on STIX 2.1 as its native data model. This skill covers building an automated IOC enrichment pipeline using OpenCTI's connector ecosystem to enrich indicators with context from VirusTotal, Shodan, AbuseIPDB, GreyNoise, and other sources. The pipeline automatically enriches newly ingested indicators, correlates them with known threat actors and campaigns, and scores them for analyst prioritization.
pycti libraryOpenCTI uses a GraphQL API frontend backed by ElasticSearch for storage and Redis/RabbitMQ for connector communication. Data is natively stored as STIX 2.1 objects with relationships. Connectors are categorized as: External Import (feed ingestion), Internal Import (file parsing), Internal Enrichment (context addition), and Stream (real-time export).
Internal enrichment connectors are triggered automatically when new observables are created or manually by analysts. Each connector receives STIX objects, queries external services, and returns STIX 2.1 bundles that augment the original observable with additional context, labels, and relationships.
OpenCTI uses a 0-100 confidence scale for indicators. Enrichment connectors can update confidence scores based on external validation: VirusTotal detection ratios, Shodan exposure data, AbuseIPDB report counts, and GreyNoise classification results.
yaml# docker-compose.yml (key services) version: '3' services: opencti: image: opencti/platform:6.4.4 environment: - APP__PORT=8080 - APP__ADMIN__EMAIL=admin@opencti.io - APP__ADMIN__PASSWORD=ChangeMeNow - APP__ADMIN__TOKEN=your-admin-token-uuid - ELASTICSEARCH__URL=http://elasticsearch:9200 - MINIO__ENDPOINT=minio - RABBITMQ__HOSTNAME=rabbitmq ports: - "8080:8080" depends_on: - elasticsearch - minio - rabbitmq - redis connector-virustotal: image: opencti/connector-virustotal:6.4.4 environment: - OPENCTI_URL=http://opencti:8080 - OPENCTI_TOKEN=your-admin-token-uuid - CONNECTOR_ID=connector-virustotal-id - CONNECTOR_NAME=VirusTotal - CONNECTOR_SCOPE=StixFile,Artifact,IPv4-Addr,Domain-Name,Url - CONNECTOR_AUTO=true - VIRUSTOTAL_TOKEN=your-vt-api-key - VIRUSTOTAL_MAX_TLP=TLP:AMBER connector-shodan: image: opencti/connector-shodan:6.4.4 environment: - OPENCTI_URL=http://opencti:8080 - OPENCTI_TOKEN=your-admin-token-uuid - CONNECTOR_ID=connector-shodan-id - CONNECTOR_NAME=Shodan - CONNECTOR_SCOPE=IPv4-Addr - CONNECTOR_AUTO=true - SHODAN_TOKEN=your-shodan-api-key - SHODAN_MAX_TLP=TLP:AMBER connector-abuseipdb: image: opencti/connector-abuseipdb:6.4.4 environment: - OPENCTI_URL=http://opencti:8080 - OPENCTI_TOKEN=your-admin-token-uuid - CONNECTOR_ID=connector-abuseipdb-id - CONNECTOR_NAME=AbuseIPDB - CONNECTOR_SCOPE=IPv4-Addr - CONNECTOR_AUTO=true - ABUSEIPDB_API_KEY=your-abuseipdb-key
pythonimport os from pycti import OpenCTIConnectorHelper, get_config_variable from stix2 import ( Bundle, Indicator, Note, Relationship, IPv4Address, DomainName ) import requests class CustomEnrichmentConnector: def __init__(self): config = { "opencti": { "url": os.environ.get("OPENCTI_URL"), "token": os.environ.get("OPENCTI_TOKEN"), }, "connector": { "id": os.environ.get("CONNECTOR_ID"), "name": "CustomEnrichment", "scope": "IPv4-Addr,Domain-Name,Url", "auto": True, "type": "INTERNAL_ENRICHMENT", }, } self.helper = OpenCTIConnectorHelper(config) self.helper.listen(self._process_message) def _process_message(self, data): entity_id = data["entity_id"] stix_object = self.helper.api.stix_cyber_observable.read(id=entity_id) if not stix_object: return "Observable not found" observable_type = stix_object["entity_type"] observable_value = stix_object.get("value", "") enrichment_results = [] if observable_type == "IPv4-Addr": enrichment_results = self._enrich_ip(observable_value, entity_id) elif observable_type == "Domain-Name": enrichment_results = self._enrich_domain(observable_value, entity_id) if enrichment_results: bundle = Bundle(objects=enrichment_results, allow_custom=True) self.helper.send_stix2_bundle(bundle.serialize()) return "Enrichment completed" def _enrich_ip(self, ip_address, entity_id): """Enrich IP address with GreyNoise, AbuseIPDB context.""" objects = [] # GreyNoise Community API try: gn_response = requests.get( f"https://api.greynoise.io/v3/community/{ip_address}", headers={"key": os.environ.get("GREYNOISE_API_KEY")}, timeout=30, ) if gn_response.status_code == 200: gn_data = gn_response.json() classification = gn_data.get("classification", "unknown") noise = gn_data.get("noise", False) riot = gn_data.get("riot", False) note_content = ( f"## GreyNoise Enrichment\n" f"- Classification: {classification}\n" f"- Internet Noise: {noise}\n" f"- RIOT (Benign Service): {riot}\n" f"- Name: {gn_data.get('name', 'N/A')}\n" f"- Last Seen: {gn_data.get('last_seen', 'N/A')}" ) note = Note( content=note_content, object_refs=[entity_id], abstract=f"GreyNoise: {classification}", allow_custom=True, ) objects.append(note) # Add labels based on classification if classification == "malicious": self.helper.api.stix_cyber_observable.add_label( id=entity_id, label_name="greynoise:malicious" ) elif riot: self.helper.api.stix_cyber_observable.add_label( id=entity_id, label_name="greynoise:benign-service" ) except Exception as e: self.helper.log_error(f"GreyNoise enrichment failed: {e}") return objects def _enrich_domain(self, domain, entity_id): """Enrich domain with WHOIS and DNS context.""" objects = [] try: # Use SecurityTrails API for domain enrichment st_response = requests.get( f"https://api.securitytrails.com/v1/domain/{domain}", headers={"APIKEY": os.environ.get("SECURITYTRAILS_API_KEY")}, timeout=30, ) if st_response.status_code == 200: st_data = st_response.json() current_dns = st_data.get("current_dns", {}) a_records = [ r.get("ip") for r in current_dns.get("a", {}).get("values", []) ] note_content = ( f"## SecurityTrails Enrichment\n" f"- A Records: {', '.join(a_records)}\n" f"- Alexa Rank: {st_data.get('alexa_rank', 'N/A')}\n" f"- Hostname: {st_data.get('hostname', 'N/A')}" ) note = Note( content=note_content, object_refs=[entity_id], abstract=f"SecurityTrails: {domain}", allow_custom=True, ) objects.append(note) except Exception as e: self.helper.log_error(f"SecurityTrails enrichment failed: {e}") return objects if __name__ == "__main__": connector = CustomEnrichmentConnector()
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-18 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-19 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-17 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-09 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-21 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-01 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-07 | pass→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-22 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-20 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-13 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-11 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-05 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-16 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-12 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-15 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-06 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-02 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-03 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-04 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-08 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-10 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-14 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +5 percentage points is the difference between those two pass rates over the 22 comparable cases.
The per-case answers from this run were removed by the retention sweep, so the case table below shows the verdicts without the text either arm produced. The counts above were recorded at the time and are unaffected. Answers are now kept for 180 days.
Other measured skills in the registry, with their headline benchmark lift.