commonfields:
  id: CyphoThreatFeed
  version: -1
vcShouldKeepItemLegacyProdMachine: false
name: CyphoThreatFeed
display: Cypho Threat Feed
category: Data Enrichment & Threat Intelligence
image: data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAABkAAAAGQCAIAAAB59ztRAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAALjBJREFUeNrs3Qt8XGWdP/7npPz2x81k1HVXbaEBdhV6oWF/CiiXpi4IyKUpgrAqNMVyUYE23NyfoCmIuALaIl5WQdsCKlfTois3NSkKBVdsilRu7pLSov7XXU2Civt3O+d3kpTeSJNJOpM8M3m/X/PK60zm5MyZ73PmJOeT53kmSdM0AAAAAECsqpQAAAAAgJgJsAAAAACImgALAAAAgKgJsAAAAACImgALAAAAgKgJsAAAAACImgALAAAAgKgJsAAAAACImgALAAAAgKgJsAAAAACImgALAAAAgKgJsAAAAACImgALAAAAgKgJsAAAAACImgALAAAAgKgJsAAAAACImgALAAAAgKgJsAAAAACImgALAAAAgKgJsAAAAACImgALAAAAgKgJsAAAAACImgALAAAAgKgJsAAAAACImgALAAAAgKgJsAAAAACImgALAAAAgKgJsAAAAACImgALAAAAgKgJsAAAAACImgALAAAAgKgJsAAAAACImgALAAAAgKgJsAAAAACImgALAAAAgKgJsAAAAACImgALAAAAgKgJsAAAAACImgALAAAAgKgJsAAAAACImgALAAAAgKgJsAAAAACImgALAAAAgKgJsAAAAACImgALAAAAgKgJsAAAAACImgALAAAAgKgJsAAAAACImgALAAAAgKgJsAAAAACImgALAAAAgKgJsAAAAACImgALAAAAgKgJsAAAAACImgALAAAAgKgJsAAAAACImgALAAAAgKgJsAAAAACImgALAAAAgKgJsAAAAACImgALAAAAgKgJsAAAAACImgALAAAAgKgJsAAAAACImgALAAAAgKgJsAAAAACImgALAAAAgKgJsAAAAACImgALAAAAgKgJsAAAAACImgALAAAAgKgJsAAAAACImgALAAAAgKgJsAAAAACImgALAAAAgKgJsAAAAACImgALAAAAgKgJsAAAAACImgALAAAAgKgJsAAAAACImgALAAAAgKgJsAAAAACImgALAAAAgKgJsAAAAACImgALAAAAgKgJsAAAAACImgALAAAAgKgJsAAAAACImgALAAAAgKgJsAAAAACImgALAAAAgKgJsAAAAACI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
description: Fetches threat intelligence indicators (IOCs) from the Cypho IOC Feeds
  API into Cortex XSOAR Threat Intel. Performs a paginated historical backfill, then
  automatically switches to incremental fetching of newly added indicators.
configuration:
- display: Server URL
  name: url
  defaultvalue: https://api.cypho.io/external/v1
  type: 0
  required: true
  additionalinfo: The Cypho External API base URL.
- display: ""
  displaypassword: API Key
  name: credentials
  type: 9
  required: true
  hiddenusername: true
  additionalinfo: The Cypho API key, sent as the X-Auth-Token header.
- display: Tenant (child company name)
  name: tenant
  type: 0
  required: false
  additionalinfo: Optional. Target a child company in a multi-tenant hierarchy. Leave
    empty to use the company that owns the API key.
- display: Fetch indicators
  name: feed
  defaultvalue: "true"
  type: 8
  required: false
- display: Fetch indicators added after
  name: first_fetch
  defaultvalue: 1 year
  type: 0
  required: false
  additionalinfo: Start boundary for the historical backfill. Accepts relative values
    such as "1 year", "6 months", "90 days", or an absolute RFC 3339 timestamp such
    as 2025-07-29T00:00:00Z. Only indicators FIRST ADDED to Cypho after this point
    are fetched. Leave empty to fetch the entire dataset from the beginning. This
    value is resolved once on the first run and then frozen, so the window cannot
    drift.
- display: Watermark overlap (minutes)
  name: overlap_minutes
  defaultvalue: "5"
  type: 0
  required: false
  additionalinfo: Rewinds the resume point by this many minutes between cycles so
    no indicator is missed due to clock differences between XSOAR and the Cypho API.
    A few indicators may be re-fetched; XSOAR de-duplicates them. Set to 0 to disable.
- display: Duplicate-detection cache size
  name: dedup_cache_size
  defaultvalue: "20000"
  type: 0
  required: false
  additionalinfo: Number of recently-seen indicator ids kept in the integration context
    to detect repeats across runs. Large enough to cover the overlap window and short-range
    pagination repeats. Set to 0 to disable if you are worried about context size.
- display: Indicators per fetch run (batch size)
  name: batch_size
  defaultvalue: "10000"
  type: 0
  required: false
  additionalinfo: How many indicators to consume per scheduled run. The run stops
    after this many and resumes from the saved cursor on the next run.
- display: API page size
  name: page_size
  defaultvalue: "1000"
  type: 0
  required: false
  additionalinfo: Indicators requested per HTTP call. Maximum allowed by the Cypho
    API is 1000.
- display: IOC type filter
  name: ioc_type
  defaultvalue: all
  type: 15
  required: false
  options:
  - all
  - ip
  - domain
  - hostname
  - url
  - hash
  additionalinfo: Restrict the feed to a single IOC type. Select "all" to fetch every
    type.
- display: Minimum confidence score
  name: min_score
  defaultvalue: "0"
  type: 0
  required: false
  additionalinfo: Skip indicators whose Cypho score (0-100) is below this value.
- display: Malicious score threshold
  name: malicious_threshold
  defaultvalue: "70"
  type: 0
  required: false
  additionalinfo: Cypho score at or above which an indicator is marked Bad in XSOAR.
- display: Suspicious score threshold
  name: suspicious_threshold
  defaultvalue: "40"
  type: 0
  required: false
  additionalinfo: Cypho score at or above which an indicator is marked Suspicious
    in XSOAR.
- display: Indicator Reputation
  name: feedReputation
  defaultvalue: SuspiciousNotMalicious
  type: 18
  required: false
  options:
  - None
  - Good
  - Suspicious
  - Bad
  additionalinfo: Overrides the per-indicator score calculation when set to anything
    other than None.
- display: Source Reliability
  name: feedReliability
  defaultvalue: B - Usually reliable
  type: 15
  required: true
  options:
  - A+ - 3rd party enrichment
  - A - Completely reliable
  - B - Usually reliable
  - C - Fairly reliable
  - D - Not usually reliable
  - E - Unreliable
  - F - Reliability cannot be judged
- display: Traffic Light Protocol Color
  name: tlp_color
  type: 15
  required: false
  options:
  - RED
  - AMBER
  - GREEN
  - WHITE
- display: ""
  name: feedExpirationPolicy
  defaultvalue: indicatorType
  type: 17
  required: false
  options:
  - never
  - interval
  - indicatorType
  - suddenDeath
- display: ""
  name: feedExpirationInterval
  defaultvalue: "20160"
  type: 1
  required: false
- display: Feed Fetch Interval
  name: feedFetchInterval
  defaultvalue: "240"
  type: 19
  required: false
  additionalinfo: How often the feed runs, in minutes. 240 = every 4 hours.
- display: Bypass exclusion list
  name: feedBypassExclusionList
  type: 8
  required: false
  additionalinfo: When selected, indicators from this feed are not filtered by the
    exclusion list.
- display: Tags
  name: feedTags
  type: 0
  required: false
  additionalinfo: Comma-separated tags applied to every indicator from this feed,
    in addition to the category and source tags derived automatically.
- display: Trust any certificate (not secure)
  name: insecure
  type: 8
  required: false
- display: Use system proxy settings
  name: proxy
  type: 8
  required: false
script:
  script: |
    """
    Cypho Threat Feed - Cortex XSOAR Feed Integration
    ==================================================
    Fetches IOC indicators from the Cypho IOC Feeds API into XSOAR Threat Intel.

    Two-phase fetch strategy
    ------------------------
      Phase 1 - BACKFILL:
          Walks the entire historical dataset using cursor pagination.
          Each scheduled run consumes `batch_size` indicators (default 10,000)
          then saves the cursor and stops. The next run resumes from that cursor.

      Phase 2 - INCREMENTAL:
          Once the cursor is exhausted the feed flips to incremental mode and
          only pulls indicators first added after the last completed cycle,
          using the API's `added_after` parameter.

    State is persisted in the integration context so progress survives restarts.
    """





    # Explicit stdlib imports. Do not rely on CommonServerPython to supply these:
    # the demistomock / CommonServerPython import lines are development-only shims
    # that get stripped when this file is unified into a deployable YAML, so
    # anything they happened to provide would otherwise vanish at runtime.
    import traceback
    import urllib.parse
    import urllib3
    from datetime import datetime, timedelta, timezone
    from typing import Any

    urllib3.disable_warnings()

    ''' CONSTANTS '''

    INTEGRATION_NAME = 'Cypho Threat Feed'
    DATE_FORMAT = '%Y-%m-%dT%H:%M:%SZ'

    DEFAULT_BASE_URL = 'https://api.cypho.io/external/v1'
    API_MAX_PAGE_SIZE = 1000          # hard ceiling enforced by the Cypho API
    DEFAULT_PAGE_SIZE = 1000
    DEFAULT_BATCH_SIZE = 10000        # indicators consumed per scheduled run
    XSOAR_CREATE_CHUNK = 2000         # indicators per demisto.createIndicators() call

    # Safety valve: never loop more than this many pages in a single run,
    # regardless of batch_size. Protects against a runaway cursor.
    MAX_PAGES_PER_RUN = 200

    PHASE_BACKFILL = 'backfill'
    PHASE_INCREMENTAL = 'incremental'

    # Cypho IOC type -> XSOAR indicator type
    TYPE_MAP = {
        'ip': FeedIndicatorType.IP,
        'domain': FeedIndicatorType.Domain,
        'hostname': FeedIndicatorType.Host,
        'url': FeedIndicatorType.URL,
        'hash': FeedIndicatorType.File,
    }

    ''' CLIENT CLASS '''


    class Client(BaseClient):
        """Thin HTTP client for the Cypho External API."""

        def __init__(self, base_url: str, api_key: str, tenant: str | None = None,
                     verify: bool = True, proxy: bool = False):
            headers = {
                'X-Auth-Token': api_key,
                'Accept': 'application/json',
            }
            super().__init__(base_url=base_url, verify=verify, proxy=proxy, headers=headers)
            self.tenant = tenant

        def _with_tenant(self, params: dict) -> dict:
            if self.tenant:
                params['tenant'] = self.tenant
            return params

        def list_indicators(self, limit: int = DEFAULT_PAGE_SIZE, cursor: str | None = None,
                            ioc_type: str | None = None, added_after: str | None = None,
                            search: str | None = None) -> dict:
            """GET /ioc-feeds/ioc/indicators - returns {'items': [...], 'nextCursor': '...'}"""
            params: dict[str, Any] = {'format': 'json', 'limit': limit}
            if cursor:
                params['cursor'] = cursor
            if ioc_type and ioc_type.lower() != 'all':
                params['type'] = ioc_type.lower()
            if added_after:
                params['added_after'] = added_after
            if search:
                params['search'] = search

            raw = self._http_request(
                method='GET',
                url_suffix='/ioc-feeds/ioc/indicators',
                params=self._with_tenant(params),
                timeout=120,
            )
            return raw.get('data') or {}

        def get_indicator(self, ioc: str) -> dict:
            """GET /ioc-feeds/ioc/indicators/{ioc}"""
            raw = self._http_request(
                method='GET',
                url_suffix=f'/ioc-feeds/ioc/indicators/{urllib.parse.quote(ioc, safe="")}',
                params=self._with_tenant({'format': 'json'}),
                ok_codes=(200, 404),
                resp_type='response',
            )
            if raw.status_code == 404:
                return {}
            return (raw.json() or {}).get('data') or {}

        def get_catalog(self) -> dict:
            raw = self._http_request(
                method='GET',
                url_suffix='/ioc-feeds/ioc/catalog',
                params=self._with_tenant({}),
            )
            return raw.get('data') or {}

        def get_stats(self, interval: str | None = None) -> dict:
            params: dict[str, Any] = {}
            if interval:
                params['interval'] = interval
            raw = self._http_request(
                method='GET',
                url_suffix='/ioc-feeds/ioc/stats',
                params=self._with_tenant(params),
            )
            return raw.get('data') or {}


    ''' HELPER FUNCTIONS '''


    def pick(item: dict, *keys, default=None):
        """
        Read the first key present in the payload.

        The Cypho OpenAPI spec is inconsistent about naming: the JSON schema
        declares camelCase (`firstSeenAt`) while the CSV example from the same
        endpoint uses snake_case (`first_seen_at`). Rather than guess which the
        live API emits, accept both. A silently-missing timestamp is worse than
        an extra dict lookup.
        """
        for key in keys:
            if key in item and item[key] is not None:
                return item[key]
        return default


    def get_first_seen(item: dict):
        return pick(item, 'firstSeenAt', 'first_seen_at')


    def get_last_seen(item: dict):
        return pick(item, 'lastSeenAt', 'last_seen_at')


    def get_updated_at(item: dict):
        return pick(item, 'updatedAt', 'updated_at')


    def now_utc_str() -> str:
        return datetime.utcnow().strftime(DATE_FORMAT)


    def to_rfc3339(dt: datetime | None) -> str | None:
        """Normalise any datetime (naive or tz-aware) to a UTC RFC 3339 string."""
        if dt is None:
            return None
        if dt.tzinfo is not None:
            dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
        return dt.strftime(DATE_FORMAT)


    def apply_overlap(timestamp: str | None, overlap_minutes: int) -> str | None:
        """
        Rewind a watermark by a few minutes before saving it.

        Two reasons this matters:
          1. `added_after` is exclusive, so an indicator sharing the exact boundary
             timestamp would be skipped.
          2. The XSOAR server clock and the Cypho API clock are not guaranteed to
             agree. Without overlap, even a few seconds of skew silently drops
             indicators, and a gap in a threat feed is far worse than re-fetching
             a handful. XSOAR de-duplicates by value+type, so overlap is cheap.
        """
        if not timestamp or overlap_minutes <= 0:
            return timestamp
        try:
            dt = datetime.strptime(timestamp, DATE_FORMAT)
        except ValueError:
            return timestamp
        return (dt - timedelta(minutes=overlap_minutes)).strftime(DATE_FORMAT)


    def resolve_xsoar_type(cypho_type: str, value: str) -> str | None:
        """Map a Cypho IOC type to the correct XSOAR indicator type."""
        cypho_type = (cypho_type or '').lower()

        if cypho_type == 'ip':
            # handles IP, IPv6, CIDR and IPv6CIDR automatically
            return FeedIndicatorType.ip_to_indicator_type(value)

        if cypho_type == 'hash':
            return FeedIndicatorType.File

        return TYPE_MAP.get(cypho_type)


    def score_to_dbot(score: int, malicious_threshold: int, suspicious_threshold: int) -> int:
        """Translate the Cypho 0-100 confidence score into an XSOAR DBot score."""
        if score >= malicious_threshold:
            return Common.DBotScore.BAD
        if score >= suspicious_threshold:
            return Common.DBotScore.SUSPICIOUS
        return Common.DBotScore.NONE


    def build_tags(item: dict, extra_tags: list[str]) -> list[str]:
        tags = set(extra_tags or [])

        category = item.get('category')
        if category:
            # categories can be compound, e.g. "botnet & malware"
            for part in str(category).split('&'):
                part = part.strip()
                if part:
                    tags.add(part)

        for source in item.get('sources') or []:
            if source:
                tags.add(str(source))

        if item.get('malware'):
            tags.add('malware')

        # flags may be nested under `flags` or flattened onto the item, and may
        # use either naming convention
        flags = item.get('flags') or {}
        if pick(flags, 'hasZeroReliability', 'has_zero_reliability') or \
                pick(item, 'hasZeroReliability', 'has_zero_reliability'):
            tags.add('low-reliability')
        if pick(flags, 'countryRisk', 'country_risk') or \
                pick(item, 'countryRisk', 'country_risk'):
            tags.add('high-risk-country')

        return sorted(tags)


    def map_indicator(item: dict, params: dict) -> dict | None:
        """Convert one Cypho IOC object into an XSOAR indicator dict."""
        value = item.get('indicator')
        if not value:
            return None

        xsoar_type = resolve_xsoar_type(item.get('type', ''), value)
        if not xsoar_type:
            demisto.debug(f'Skipping indicator with unmappable type: {item.get("type")} / {value}')
            return None

        raw_score = int(pick(item, 'score', default=0) or 0)
        dbot_score = score_to_dbot(
            raw_score,
            params['malicious_threshold'],
            params['suspicious_threshold'],
        )

        first_seen = get_first_seen(item)
        last_seen = get_last_seen(item)

        fields = {
            # "By source" fields - the conventional target for feed integrations
            'firstseenbysource': first_seen,
            'lastseenbysource': last_seen,
            # System columns shown in the Threat Intel grid. Setting both means the
            # dates render regardless of which pair the grid is configured against.
            'firstSeen': first_seen,
            'lastSeen': last_seen,
            'updateddate': get_updated_at(item),
            'sourceoriginalseverity': raw_score,
            'reportedby': 'Cypho',
            'tags': build_tags(item, params['feed_tags']),
            'trafficlightprotocol': params['tlp_color'],
        }

        country = pick(item, 'country')
        if country:
            fields['geocountry'] = country

        description_parts = []
        if item.get('category'):
            description_parts.append(f"Category: {item['category']}")
        if item.get('sources'):
            description_parts.append(f"Sources: {', '.join(item['sources'])}")
        usage = pick(item, 'usage')
        if usage:
            description_parts.append(f"Usage: {usage}")
        if description_parts:
            fields['description'] = ' | '.join(description_parts)

        # strip empty values so we don't overwrite existing data with nulls
        fields = {k: v for k, v in fields.items() if v not in (None, '', [])}

        return {
            'value': value,
            'type': xsoar_type,
            'service': 'Cypho Threat Feed',
            'score': dbot_score,
            'fields': fields,
            'rawJSON': item,
        }


    def create_indicators_in_chunks(indicators: list[dict]) -> int:
        """Push indicators to XSOAR in safe-sized chunks."""
        created = 0
        for i in range(0, len(indicators), XSOAR_CREATE_CHUNK):
            chunk = indicators[i:i + XSOAR_CREATE_CHUNK]
            demisto.createIndicators(chunk)
            created += len(chunk)
        return created


    def get_config(params: dict) -> dict:
        """Normalise and validate instance configuration."""
        page_size = arg_to_number(params.get('page_size')) or DEFAULT_PAGE_SIZE
        page_size = max(1, min(page_size, API_MAX_PAGE_SIZE))

        batch_size = arg_to_number(params.get('batch_size')) or DEFAULT_BATCH_SIZE
        batch_size = max(page_size, batch_size)

        malicious_threshold = arg_to_number(params.get('malicious_threshold'))
        malicious_threshold = 70 if malicious_threshold is None else malicious_threshold

        suspicious_threshold = arg_to_number(params.get('suspicious_threshold'))
        suspicious_threshold = 40 if suspicious_threshold is None else suspicious_threshold

        min_score = arg_to_number(params.get('min_score'))
        min_score = 0 if min_score is None else min_score

        dedup_cache_size = arg_to_number(params.get('dedup_cache_size'))
        dedup_cache_size = 20000 if dedup_cache_size is None else max(0, dedup_cache_size)

        overlap_minutes = arg_to_number(params.get('overlap_minutes'))
        overlap_minutes = 5 if overlap_minutes is None else max(0, overlap_minutes)

        # Start boundary for the backfill. Accepts relative expressions such as
        # "1 year", "6 months", "30 days", or an absolute RFC 3339 timestamp.
        # Empty means: fetch the entire dataset from the beginning of time.
        first_fetch_raw = (params.get('first_fetch') or '').strip()
        first_fetch = None
        if first_fetch_raw:
            parsed = arg_to_datetime(first_fetch_raw, arg_name='Fetch indicators added after')
            if not parsed:
                raise DemistoException(
                    f'Could not understand the start date "{first_fetch_raw}". '
                    'Use a relative value such as "1 year", "6 months", "30 days", '
                    'or an absolute timestamp such as "2025-07-29T00:00:00Z".'
                )
            first_fetch = to_rfc3339(parsed)

        return {
            'page_size': page_size,
            'batch_size': batch_size,
            'ioc_type': params.get('ioc_type') or 'all',
            'min_score': min_score,
            'malicious_threshold': malicious_threshold,
            'suspicious_threshold': suspicious_threshold,
            'tlp_color': params.get('tlp_color') or '',
            'feed_tags': argToList(params.get('feedTags')),
            'first_fetch': first_fetch,
            'first_fetch_raw': first_fetch_raw,
            'overlap_minutes': overlap_minutes,
            'dedup_cache_size': dedup_cache_size,
        }


    def merge_indicators(a: dict, b: dict) -> dict:
        """
        Combine two rows that map to the same XSOAR value+type.

        Sending both to createIndicators would let the later one blindly overwrite
        the earlier, silently discarding tags and widening nothing. Merging
        explicitly keeps the strongest signal: highest score, union of tags,
        earliest first-seen and latest last-seen.
        """
        winner, other = (b, a) if int(b.get('score') or 0) > int(a.get('score') or 0) else (a, b)
        fw, fo = winner.get('fields') or {}, other.get('fields') or {}
        fields = dict(fw)

        tags = set(fw.get('tags') or []) | set(fo.get('tags') or [])
        if tags:
            fields['tags'] = sorted(tags)

        for key, take_earliest in (('firstseenbysource', True), ('firstSeen', True),
                                   ('lastseenbysource', False), ('lastSeen', False)):
            vw, vo = fw.get(key), fo.get(key)
            if vw and vo:
                fields[key] = min(vw, vo) if take_earliest else max(vw, vo)
            elif vo and not vw:
                fields[key] = vo

        sw, so = fw.get('sourceoriginalseverity'), fo.get('sourceoriginalseverity')
        if isinstance(sw, int) and isinstance(so, int):
            fields['sourceoriginalseverity'] = max(sw, so)

        out = dict(winner)
        out['fields'] = fields
        return out


    def indicator_key(item: dict) -> str | None:
        """Stable identity for an indicator: Cypho's id, else type+value."""
        ident = pick(item, 'id')
        if ident:
            return str(ident)
        value = pick(item, 'indicator')
        if not value:
            return None
        return f'{pick(item, "type", default="?")}:{value}'


    def compact_key(key: str) -> str:
        """
        Shorten a key for the seen-cache. Cypho ids are long SHA-1 strings; the
        trailing 16 characters keep collision risk negligible at cache sizes in
        the tens of thousands while cutting stored bytes by roughly two thirds.
        """
        return key[-16:] if len(key) > 16 else key


    ''' FETCH LOGIC '''


    def _consume_pages(client: Client, cfg: dict, cursor: str | None,
                       added_after: str | None, seen_cache: list) -> dict:
        """
        Page through the API until `batch_size` indicators are consumed or the
        dataset is exhausted.

        Returns a dict with the mapped indicators, the next cursor, whether the
        dataset was exhausted, and the firstSeenAt age range of this batch.
        The age range is what tells you whether the feed is still chewing through
        historical data or has caught up to newly-added indicators.
        """
        merged_by_value: dict = {}
        seen = 0
        pages = 0
        exhausted = False
        oldest: str | None = None
        newest: str | None = None
        # First and last firstSeenAt in traversal order. Comparing them reveals
        # whether the API walks the dataset oldest-first or newest-first, which
        # changes how backfill progress should be read.
        traversal_first: str | None = None
        traversal_last: str | None = None

        seen_this_run: set = set()
        # XSOAR keys indicators on value+type, NOT on the source's id. Tracking
        # both is the only way to explain why the TIM count is lower than the
        # number of rows the API returned.
        seen_values: set = set()
        value_collisions = 0
        skipped_low_score = 0
        skipped_unmappable = 0
        unmappable_types: dict = {}
        dupes_in_run = 0
        dupes_vs_history = 0
        cursor_stalled = False
        new_keys: list = []
        history = set(seen_cache or [])

        while seen < cfg['batch_size'] and pages < MAX_PAGES_PER_RUN:
            remaining = cfg['batch_size'] - seen
            limit = min(cfg['page_size'], remaining)

            data = client.list_indicators(
                limit=limit,
                cursor=cursor,
                ioc_type=cfg['ioc_type'],
                added_after=added_after,
            )

            items = data.get('items') or []
            next_cursor = data.get('nextCursor') or data.get('next_cursor')
            pages += 1

            if pages == 1 and items:
                sample = items[0]
                style = ('camelCase' if 'firstSeenAt' in sample
                         else 'snake_case' if 'first_seen_at' in sample
                         else 'UNKNOWN - no first-seen field found')
                demisto.debug(
                    f'{INTEGRATION_NAME}: API field naming detected = {style}. '
                    f'Sample keys: {sorted(sample.keys())}'
                )
            seen += len(items)

            for item in items:
                key = indicator_key(item)
                if key:
                    if key in seen_this_run:
                        dupes_in_run += 1
                    else:
                        seen_this_run.add(key)
                        ck = compact_key(key)
                        if ck in history:
                            dupes_vs_history += 1
                        else:
                            new_keys.append(ck)

                first_seen = get_first_seen(item)
                if first_seen:
                    if oldest is None or first_seen < oldest:
                        oldest = first_seen
                    if newest is None or first_seen > newest:
                        newest = first_seen
                    if traversal_first is None:
                        traversal_first = first_seen
                    traversal_last = first_seen

                if int(pick(item, 'score', default=0) or 0) < cfg['min_score']:
                    skipped_low_score += 1
                    continue

                indicator = map_indicator(item, cfg)
                if not indicator:
                    skipped_unmappable += 1
                    t = str(pick(item, 'type', default='unknown'))
                    unmappable_types[t] = unmappable_types.get(t, 0) + 1
                    continue

                # This is the key XSOAR will store under. A repeat here means the
                # two rows merge into one indicator in TIM.
                vkey = f'{indicator["type"]}:{indicator["value"]}'
                existing = merged_by_value.get(vkey)
                if existing is not None:
                    value_collisions += 1
                    merged_by_value[vkey] = merge_indicators(existing, indicator)
                else:
                    seen_values.add(vkey)
                    merged_by_value[vkey] = indicator

            demisto.debug(
                f'{INTEGRATION_NAME}: page {pages} -> {len(items)} items, '
                f'{len(merged_by_value)} distinct so far, nextCursor={"yes" if next_cursor else "no"}'
            )

            if not items or not next_cursor:
                exhausted = True
                cursor = None
                break

            # Stall guard: an unchanged cursor means the next request replays the
            # same page. Without this, the loop burns every remaining page slot
            # re-reading identical data and reports it as progress.
            if next_cursor == cursor:
                cursor_stalled = True
                demisto.error(
                    f'{INTEGRATION_NAME}: cursor did not advance ({cursor}). '
                    f'Stopping this run to avoid re-reading the same page.'
                )
                break

            cursor = next_cursor

        return {
            'indicators': list(merged_by_value.values()),
            'next_cursor': cursor,
            'exhausted': exhausted,
            'seen': seen,
            'pages': pages,
            'oldest_first_seen': oldest,
            'newest_first_seen': newest,
            'traversal_first': traversal_first,
            'traversal_last': traversal_last,
            'unique_this_run': len(seen_this_run),
            'distinct_indicators': len(seen_values),
            'value_collisions': value_collisions,
            'skipped_low_score': skipped_low_score,
            'skipped_unmappable': skipped_unmappable,
            'unmappable_types': unmappable_types,
            'dupes_in_run': dupes_in_run,
            'dupes_vs_history': dupes_vs_history,
            'cursor_stalled': cursor_stalled,
            'new_keys': new_keys,
        }


    def fetch_indicators_command(client: Client, cfg: dict) -> None:
        """Scheduled feed command. Runs on the configured feed fetch interval."""
        ctx = demisto.getIntegrationContext() or {}
        phase = ctx.get('phase') or PHASE_BACKFILL

        ctx['phase'] = phase

        if phase == PHASE_BACKFILL:
            cursor = ctx.get('cursor')

            # Freeze the start boundary on the very first run. Recomputing
            # "1 year ago" every run would slide the window forward and silently
            # skip indicators, so the resolved timestamp is stored once and reused.
            if not ctx.get('backfill_started_at'):
                ctx['backfill_started_at'] = now_utc_str()
                ctx['backfill_added_after'] = cfg.get('first_fetch')
                ctx['backfill_range_label'] = cfg.get('first_fetch_raw') or 'entire history'

            added_after = ctx.get('backfill_added_after')
        else:
            cursor = ctx.get('cursor')
            added_after = ctx.get('last_added_after')
            if not cursor:
                # starting a fresh incremental cycle - stamp the start time now so
                # indicators added *during* this cycle are not skipped next time
                ctx['cycle_started_at'] = now_utc_str()

        demisto.debug(
            f'{INTEGRATION_NAME}: starting fetch | phase={phase} '
            f'cursor={"set" if cursor else "none"} added_after={added_after}'
        )

        seen_cache = ctx.get('seen_ids') or []
        result = _consume_pages(client, cfg, cursor, added_after, seen_cache)
        indicators = result['indicators']
        next_cursor = result['next_cursor']
        exhausted = result['exhausted']
        seen = result['seen']

        created = create_indicators_in_chunks(indicators)

        # --- persist state ---
        ctx['total_fetched'] = int(ctx.get('total_fetched') or 0) + seen
        ctx['total_created'] = int(ctx.get('total_created') or 0) + created
        ctx['last_run_at'] = now_utc_str()
        ctx['last_run_count'] = created
        ctx['last_run_seen'] = seen
        ctx['last_run_pages'] = result['pages']

        # --- duplicate accounting ---
        ctx['last_run_unique'] = result['unique_this_run']
        ctx['last_run_distinct'] = result['distinct_indicators']
        ctx['last_run_value_collisions'] = result['value_collisions']
        ctx['last_run_skipped_low_score'] = result['skipped_low_score']
        ctx['last_run_skipped_unmappable'] = result['skipped_unmappable']
        ctx['last_run_unmappable_types'] = result['unmappable_types']
        ctx['total_value_collisions'] = (int(ctx.get('total_value_collisions') or 0)
                                         + result['value_collisions'])
        ctx['total_skipped_unmappable'] = (int(ctx.get('total_skipped_unmappable') or 0)
                                           + result['skipped_unmappable'])
        ctx['total_distinct'] = int(ctx.get('total_distinct') or 0) + result['distinct_indicators']
        ctx['last_run_dupes_in_run'] = result['dupes_in_run']
        ctx['last_run_dupes_vs_history'] = result['dupes_vs_history']
        ctx['total_dupes_in_run'] = (int(ctx.get('total_dupes_in_run') or 0)
                                     + result['dupes_in_run'])
        ctx['total_dupes_vs_history'] = (int(ctx.get('total_dupes_vs_history') or 0)
                                         + result['dupes_vs_history'])
        if result['cursor_stalled']:
            ctx['cursor_stalls'] = int(ctx.get('cursor_stalls') or 0) + 1
            ctx['last_cursor_stall_at'] = now_utc_str()

        # Bounded FIFO cache of recently-seen ids. Storing every id from a
        # multi-million-row dataset would blow past the integration context size
        # limit, so keep only the most recent window - enough to catch the
        # overlap-window re-reads and any short-range pagination repeats.
        cache_size = cfg.get('dedup_cache_size', 20000)
        if cache_size > 0:
            merged = (seen_cache or []) + result['new_keys']
            ctx['seen_ids'] = merged[-cache_size:]
        else:
            ctx.pop('seen_ids', None)

        # Age range of the batch just fetched. This is the clearest signal of
        # whether the feed is still on historical data or has caught up.
        ctx['last_batch_oldest_first_seen'] = result['oldest_first_seen']
        ctx['last_batch_newest_first_seen'] = result['newest_first_seen']

        # Which way is the API walking the dataset?
        t_first, t_last = result['traversal_first'], result['traversal_last']
        if t_first and t_last and t_first != t_last:
            ctx['fetch_order'] = 'ascending' if t_last > t_first else 'descending'
        ctx['last_batch_traversal_first'] = t_first
        ctx['last_batch_traversal_last'] = t_last

        # Track the newest firstSeenAt observed across all runs. This drives the
        # incremental watermark: deriving it from the DATA rather than from the
        # XSOAR server clock makes the feed immune to clock skew between XSOAR
        # and the Cypho API.
        newest = result['newest_first_seen']
        if newest and (not ctx.get('max_first_seen') or newest > ctx['max_first_seen']):
            ctx['max_first_seen'] = newest

        def _next_watermark() -> str:
            """Resume point for the next cycle, rewound by the overlap window."""
            base = ctx.get('max_first_seen') or ctx.get('cycle_started_at') or now_utc_str()
            return apply_overlap(base, cfg.get('overlap_minutes', 5)) or base

        if phase == PHASE_BACKFILL:
            if exhausted:
                ctx['phase'] = PHASE_INCREMENTAL
                ctx['cursor'] = None
                ctx['backfill_completed_at'] = now_utc_str()
                ctx['last_added_after'] = _next_watermark()
                demisto.debug(
                    f'{INTEGRATION_NAME}: backfill COMPLETE, switching to incremental '
                    f'from watermark {ctx["last_added_after"]}'
                )
            else:
                ctx['cursor'] = next_cursor
        else:
            if exhausted:
                ctx['last_added_after'] = _next_watermark()
                ctx['cursor'] = None
            else:
                ctx['cursor'] = next_cursor

        demisto.setIntegrationContext(ctx)

        demisto.debug(
            f'{INTEGRATION_NAME}: run finished | seen={seen} created={created} '
            f'phase={ctx.get("phase")} exhausted={exhausted}'
        )


    ''' COMMAND FUNCTIONS '''


    def test_module_command(client: Client, cfg: dict) -> str:
        """Verify connectivity and credentials, with actionable error messages."""
        try:
            client.list_indicators(limit=1, ioc_type=cfg['ioc_type'])
        except DemistoException as e:
            msg = str(e)
            if '401' in msg or 'Unauthorized' in msg:
                raise DemistoException(
                    'Authentication failed (HTTP 401). Check that:\n'
                    '  1. The API Key is correct and has not expired.\n'
                    '  2. The key is pasted with no leading/trailing whitespace.\n'
                    '  3. If you set a Tenant, that the key belongs to a PARENT company '
                    'of that tenant.\n'
                    f'Raw error: {msg}'
                )
            if '403' in msg or 'Forbidden' in msg:
                raise DemistoException(
                    'Access denied (HTTP 403). The API key is valid but lacks permission '
                    'for the IOC Feeds API. Ask Cypho to enable threat-intelligence feed '
                    f'access for this key.\nRaw error: {msg}'
                )
            if '404' in msg:
                raise DemistoException(
                    'Endpoint not found (HTTP 404). Check the Server URL — it should be '
                    f'https://api.cypho.io/external/v1 with no trailing path.\nRaw error: {msg}'
                )
            raise
        return 'ok'


    def get_indicators_command(client: Client, cfg: dict, args: dict) -> CommandResults:
        """Manual preview - fetches indicators WITHOUT writing them to Threat Intel."""
        limit = arg_to_number(args.get('limit')) or 10
        limit = max(1, min(limit, API_MAX_PAGE_SIZE))

        data = client.list_indicators(
            limit=limit,
            cursor=args.get('cursor'),
            ioc_type=args.get('type') or cfg['ioc_type'],
            added_after=args.get('added_after'),
            search=args.get('search'),
        )
        items = data.get('items') or []

        table = tableToMarkdown(
            f'Cypho IOC Indicators ({len(items)})',
            items,
            headers=['indicator', 'type', 'category', 'score', 'country', 'sources',
                     'firstSeenAt', 'lastSeenAt'],
            removeNull=True,
            headerTransform=string_to_table_header,
        )
        if data.get('nextCursor'):
            table += f'\n**Next cursor:** `{data["nextCursor"]}`'

        return CommandResults(
            outputs_prefix='Cypho.IOC',
            outputs_key_field='id',
            outputs=items,
            readable_output=table,
            raw_response=data,
        )


    def get_indicator_command(client: Client, args: dict) -> CommandResults:
        ioc = args.get('ioc')
        if not ioc:
            raise DemistoException('The "ioc" argument is required.')

        item = client.get_indicator(ioc)
        if not item:
            return CommandResults(readable_output=f'Indicator **{ioc}** was not found in the Cypho feed.')

        return CommandResults(
            outputs_prefix='Cypho.IOC',
            outputs_key_field='id',
            outputs=item,
            readable_output=tableToMarkdown(
                f'Cypho IOC: {ioc}',
                item,
                headers=['indicator', 'type', 'category', 'score', 'country', 'usage',
                         'sources', 'seenRunCount', 'malware', 'firstSeenAt', 'lastSeenAt', 'updatedAt'],
                removeNull=True,
                headerTransform=string_to_table_header,
            ),
            raw_response=item,
        )


    def get_catalog_command(client: Client) -> CommandResults:
        data = client.get_catalog()
        readable = ''
        readable += tableToMarkdown('IOC Types', data.get('types') or [], removeNull=True,
                                    headerTransform=string_to_table_header)
        readable += tableToMarkdown('Categories', data.get('categories') or [], removeNull=True,
                                    headerTransform=string_to_table_header)
        readable += tableToMarkdown('Sources', data.get('sources') or [], removeNull=True,
                                    headerTransform=string_to_table_header)

        return CommandResults(
            outputs_prefix='Cypho.Catalog',
            outputs=data,
            readable_output=readable or 'No catalog data returned.',
            raw_response=data,
        )


    def get_stats_command(client: Client, args: dict) -> CommandResults:
        data = client.get_stats(interval=args.get('interval'))
        return CommandResults(
            outputs_prefix='Cypho.IOCStats',
            outputs=data,
            readable_output=tableToMarkdown('Cypho IOC Feed Statistics', data, removeNull=True,
                                            headerTransform=string_to_table_header),
            raw_response=data,
        )


    def decode_cursor_timestamp(cursor: str | None) -> str | None:
        """
        Cypho cursors look like '1782815400:example.com' - the first segment is a
        unix timestamp marking the position in the dataset. Decoding it shows
        roughly where in the timeline the backfill currently sits.
        """
        if not cursor or ':' not in cursor:
            return None
        try:
            ts = int(cursor.split(':', 1)[0])
            decoded = datetime.utcfromtimestamp(ts)
        except (ValueError, OverflowError, OSError):
            return None

        # Sanity guard: only trust the decode if it lands in a plausible window.
        # If Cypho ever changes the cursor format, showing nothing beats showing
        # a nonsense date like 1970-01-01.
        now = datetime.utcnow()
        if not (datetime(2015, 1, 1) <= decoded <= now.replace(year=now.year + 1)):
            return None

        return decoded.strftime(DATE_FORMAT)


    def feed_status_command(client: Client, cfg: dict) -> CommandResults:
        """
        Show whether the feed is still backfilling history or has caught up to
        newly-added indicators, with live progress against the real dataset total.
        """
        ctx = demisto.getIntegrationContext() or {}
        if not ctx:
            return CommandResults(
                readable_output='The feed has not run yet - no state stored. '
                                'It will start a full backfill on the first scheduled run.'
            )

        phase = ctx.get('phase') or PHASE_BACKFILL
        total_fetched = int(ctx.get('total_fetched') or 0)

        # --- live total from the API, so progress is real and not guessed ---
        dataset_total = None
        stats_error = None
        try:
            stats = client.get_stats()
            dataset_total = stats.get('total')
            if cfg['ioc_type'] and cfg['ioc_type'] != 'all':
                by_type = stats.get('by_type') or {}
                dataset_total = by_type.get(cfg['ioc_type'], dataset_total)
        except Exception as e:  # non-fatal - status should still render
            stats_error = str(e)

        summary = {
            'Phase': 'BACKFILL - still reading historical indicators'
                     if phase == PHASE_BACKFILL
                     else 'INCREMENTAL - only fetching newly added indicators',
            'Indicators Seen From API': f'{total_fetched:,}',
            'Distinct Indicators Written': f'{int(ctx.get("total_created") or 0):,}',
            'Last Run At': ctx.get('last_run_at') or 'Never',
            'Last Run Wrote': f'{int(ctx.get("last_run_count") or 0):,} distinct indicators',
        }

        # --- progress + ETA (backfill only) ---
        bounded = bool(ctx.get('backfill_added_after'))
        if phase == PHASE_BACKFILL and dataset_total and not bounded:
            pct = (total_fetched / dataset_total * 100) if dataset_total else 0
            remaining = max(0, dataset_total - total_fetched)
            runs_left = -(-remaining // cfg['batch_size']) if cfg['batch_size'] else 0

            summary['Dataset Total'] = f'{dataset_total:,}'
            summary['Progress'] = f'{pct:.2f}%  ({total_fetched:,} / {dataset_total:,})'
            summary['Remaining'] = f'{remaining:,}'
            summary['Estimated Runs Left'] = f'{runs_left:,}'

            interval_min = arg_to_number(demisto.params().get('feedFetchInterval')) or 240
            hours_left = runs_left * interval_min / 60
            if hours_left >= 48:
                summary['Estimated Time To Finish'] = f'{hours_left / 24:.1f} days'
            else:
                summary['Estimated Time To Finish'] = f'{hours_left:.1f} hours'
        elif dataset_total:
            summary['Dataset Total'] = f'{dataset_total:,}'
            if bounded and phase == PHASE_BACKFILL:
                summary['Progress'] = (
                    'Not shown - a start date is set, so the API total covers more '
                    'than this feed will fetch. Track "Last Batch" dates instead.'
                )

        if stats_error:
            summary['Dataset Total'] = f'Unavailable ({stats_error})'

        # --- the direct answer: how old were the indicators in the last batch ---
        oldest = ctx.get('last_batch_oldest_first_seen')
        newest = ctx.get('last_batch_newest_first_seen')
        if oldest or newest:
            summary['Last Batch - Oldest Indicator'] = oldest or 'n/a'
            summary['Last Batch - Newest Indicator'] = newest or 'n/a'

        # --- reconciliation: why the TIM count is lower than the rows fetched ---
        rows = int(ctx.get('last_run_seen') or 0)
        d_run = int(ctx.get('last_run_dupes_in_run') or 0)
        d_hist = int(ctx.get('last_run_dupes_vs_history') or 0)
        low = int(ctx.get('last_run_skipped_low_score') or 0)
        unmap = int(ctx.get('last_run_skipped_unmappable') or 0)
        coll = int(ctx.get('last_run_value_collisions') or 0)
        distinct = int(ctx.get('last_run_distinct') or 0)

        if rows:
            summary['Last Run - Rows From API'] = f'{rows:,}'
            summary['  minus below min score'] = f'-{low:,}'
            unmap_detail = ''
            types_map = ctx.get('last_run_unmappable_types') or {}
            if types_map:
                unmap_detail = '   [' + ', '.join(
                    f'{k}: {v}' for k, v in sorted(types_map.items(),
                                                   key=lambda x: -x[1])[:4]) + ']'
            summary['  minus unmappable type'] = f'-{unmap:,}{unmap_detail}'
            summary['  minus same value+type'] = (
                f'-{coll:,}   (rows combined into one indicator, no data lost)')
            summary['  = DISTINCT written to TIM'] = f'{distinct:,}'

            checksum = low + unmap + coll + distinct
            summary['  reconciles?'] = (
                'YES' if checksum == rows
                else f'NO - {rows:,} rows vs {checksum:,} accounted for')

        summary['Last Run - Repeats Within Run (by id)'] = (
            f'{d_run:,}' + ('  <- pagination issue' if d_run else '  (clean)'))
        summary['Last Run - Already Seen Recently'] = (
            f'{d_hist:,}' + ('  (expected from overlap window)' if d_hist else ''))
        summary['Total Same value+type Merged'] = f'{int(ctx.get("total_value_collisions") or 0):,}'
        summary['Total Unmappable Skipped'] = f'{int(ctx.get("total_skipped_unmappable") or 0):,}'
        summary['Total Repeats Within Runs'] = f'{int(ctx.get("total_dupes_in_run") or 0):,}'
        summary['Dedup Cache Size'] = f'{len(ctx.get("seen_ids") or []):,} ids retained'

        stalls = int(ctx.get('cursor_stalls') or 0)
        if stalls:
            summary['Cursor Stalls Detected'] = (
                f'{stalls}  <- API returned an unchanged cursor; last at '
                f'{ctx.get("last_cursor_stall_at")}')

        cursor = ctx.get('cursor')
        cursor_ts = decode_cursor_timestamp(cursor)
        summary['Saved Cursor'] = cursor or 'None (cycle complete / not started)'
        if cursor_ts:
            summary['Cursor Position (approx date)'] = cursor_ts

        if phase == PHASE_INCREMENTAL:
            summary['Incremental Watermark'] = ctx.get('last_added_after') or 'n/a'
            summary['Meaning'] = 'Only indicators added after the watermark are fetched.'

        order = ctx.get('fetch_order')
        if order == 'ascending':
            summary['Fetch Order'] = 'OLDEST first - walking forward towards today'
        elif order == 'descending':
            summary['Fetch Order'] = 'NEWEST first - walking backwards into history'
        elif ctx.get('last_run_at'):
            summary['Fetch Order'] = 'Undetermined (all indicators shared one timestamp)'

        summary['Fetch Range'] = ctx.get('backfill_range_label') or 'entire history'
        boundary = ctx.get('backfill_added_after')
        summary['Fetch From (frozen at first run)'] = boundary or 'Beginning of dataset'
        summary['Backfill Started'] = ctx.get('backfill_started_at') or 'n/a'
        summary['Backfill Completed'] = ctx.get('backfill_completed_at') or 'Not yet'

        readable = tableToMarkdown(
            f'Cypho Threat Feed - Status ({"BACKFILL" if phase == PHASE_BACKFILL else "INCREMENTAL"})',
            summary,
            removeNull=False,
        )

        if phase == PHASE_BACKFILL:
            readable += ('\n> The feed is reading **historical** indicators. It will switch to '
                         'new-indicator-only mode automatically once the dataset is exhausted.\n')
            if order == 'ascending':
                readable += ('> Batch dates will move **forward** each run. The backfill is '
                             'finished when they reach today.\n')
            elif order == 'descending':
                readable += ('> The API returns newest indicators first, so batch dates move '
                             '**backwards** each run. The backfill is finished when they reach '
                             'your configured start date.\n')
        else:
            readable += ('\n> The backfill is finished. Every run now fetches **only indicators '
                         'added since the last cycle**.\n')

        outputs = dict(ctx)
        outputs['dataset_total'] = dataset_total

        return CommandResults(
            outputs_prefix='Cypho.FeedStatus',
            outputs=outputs,
            readable_output=readable,
            raw_response=outputs,
        )


    def feed_reset_command(args: dict) -> CommandResults:
        """Reset feed state. Use with care - restarts the full backfill."""
        if not argToBoolean(args.get('confirm', 'false')):
            return CommandResults(
                readable_output='Nothing was reset. Re-run with `confirm=true` to wipe the saved '
                                'cursor and restart the full backfill from the beginning.'
            )
        start_from = (args.get('start_from') or '').strip()
        new_ctx: dict = {}
        note = 'from the start date configured on the instance'

        if start_from:
            parsed = arg_to_datetime(start_from, arg_name='start_from')
            if not parsed:
                raise DemistoException(
                    f'Could not understand start_from="{start_from}". Use something like '
                    '"1 year", "30 days", or "2025-07-29T00:00:00Z".'
                )
            resolved = to_rfc3339(parsed)
            new_ctx = {
                'backfill_started_at': now_utc_str(),
                'backfill_added_after': resolved,
                'backfill_range_label': f'{start_from} (set via feed-reset)',
                'phase': PHASE_BACKFILL,
            }
            note = f'from {resolved}'

        demisto.setIntegrationContext(new_ctx)
        return CommandResults(
            readable_output=f'Feed state cleared. The next scheduled run will start a fresh '
                            f'backfill {note}.'
        )


    ''' MAIN '''


    def main() -> None:
        params = demisto.params()
        args = demisto.args()
        command = demisto.command()

        base_url = (params.get('url') or DEFAULT_BASE_URL).rstrip('/')
        api_key = (params.get('credentials') or {}).get('password') or params.get('api_key')
        tenant = params.get('tenant') or None
        verify = not params.get('insecure', False)
        proxy = params.get('proxy', False)

        cfg = get_config(params)

        demisto.debug(f'{INTEGRATION_NAME}: command "{command}" invoked')

        try:
            if not api_key:
                raise DemistoException('API Key is required. Set it in the instance configuration.')

            client = Client(base_url=base_url, api_key=api_key, tenant=tenant,
                            verify=verify, proxy=proxy)

            if command == 'test-module':
                return_results(test_module_command(client, cfg))

            elif command == 'fetch-indicators':
                fetch_indicators_command(client, cfg)

            elif command == 'cypho-get-indicators':
                return_results(get_indicators_command(client, cfg, args))

            elif command == 'cypho-get-indicator':
                return_results(get_indicator_command(client, args))

            elif command == 'cypho-get-catalog':
                return_results(get_catalog_command(client))

            elif command == 'cypho-get-ioc-stats':
                return_results(get_stats_command(client, args))

            elif command == 'cypho-feed-status':
                return_results(feed_status_command(client, cfg))

            elif command == 'cypho-feed-reset':
                return_results(feed_reset_command(args))

            else:
                raise NotImplementedError(f'Command "{command}" is not implemented.')

        except Exception as e:
            demisto.error(traceback.format_exc())
            return_error(f'Failed to execute "{command}". Error: {str(e)}')


    if __name__ in ('__main__', '__builtin__', 'builtins'):
        main()
  type: python
  commands:
  - name: cypho-get-indicators
    arguments:
    - name: limit
      description: Number of indicators to return (1-1000).
      defaultValue: "10"
    - name: type
      auto: PREDEFINED
      predefined:
      - all
      - ip
      - domain
      - hostname
      - url
      - hash
      description: Filter by IOC type.
    - name: search
      description: Case-insensitive substring match against the indicator value.
    - name: added_after
      description: Return only indicators first added after this RFC 3339 timestamp,
        for example 2026-06-30T10:00:00Z.
    - name: cursor
      description: Pagination cursor from a previous response.
    outputs:
    - contextPath: Cypho.IOC.id
      description: Stable Cypho identifier for the indicator.
      type: String
    - contextPath: Cypho.IOC.indicator
      description: The IOC value.
      type: String
    - contextPath: Cypho.IOC.type
      description: IOC type (ip, domain, hostname, url, hash).
      type: String
    - contextPath: Cypho.IOC.category
      description: Category label resolved from contributing sources.
      type: String
    - contextPath: Cypho.IOC.score
      description: Cypho confidence score, 0-100.
      type: Number
    - contextPath: Cypho.IOC.sources
      description: Upstream sources that reported the indicator.
      type: Unknown
    - contextPath: Cypho.IOC.country
      description: Country code associated with the indicator.
      type: String
    - contextPath: Cypho.IOC.firstSeenAt
      description: First time the indicator was seen.
      type: Date
    - contextPath: Cypho.IOC.lastSeenAt
      description: Most recent time the indicator was seen.
      type: Date
    description: Preview indicators from the Cypho feed without writing them to Threat
      Intel. Useful for testing filters and inspecting the raw data.
  - name: cypho-get-indicator
    arguments:
    - name: ioc
      required: true
      description: The indicator value to look up (IP, domain, hostname, URL, or hash).
    outputs:
    - contextPath: Cypho.IOC.id
      description: Stable Cypho identifier for the indicator.
      type: String
    - contextPath: Cypho.IOC.indicator
      description: The IOC value.
      type: String
    - contextPath: Cypho.IOC.type
      description: IOC type.
      type: String
    - contextPath: Cypho.IOC.score
      description: Cypho confidence score, 0-100.
      type: Number
    - contextPath: Cypho.IOC.category
      description: Category label.
      type: String
    description: Retrieve a single indicator from the Cypho feed by value.
  - name: cypho-get-catalog
    arguments: []
    outputs:
    - contextPath: Cypho.Catalog.types
      description: Supported IOC types.
      type: Unknown
    - contextPath: Cypho.Catalog.categories
      description: Available indicator categories.
      type: Unknown
    - contextPath: Cypho.Catalog.sources
      description: Upstream sources with tier and reliability scores.
      type: Unknown
    description: List the IOC types, categories, and upstream sources available in
      the Cypho feed.
  - name: cypho-get-ioc-stats
    arguments:
    - name: interval
      description: Optional time window restricting the aggregate to indicators first
        seen within it.
    outputs:
    - contextPath: Cypho.IOCStats
      description: Aggregate IOC feed statistics.
      type: Unknown
    description: Return aggregate counts for the Cypho IOC dataset - totals by type,
      category, and source.
  - name: cypho-feed-status
    arguments: []
    outputs:
    - contextPath: Cypho.FeedStatus.phase
      description: Current fetch phase, either backfill or incremental.
      type: String
    - contextPath: Cypho.FeedStatus.total_fetched
      description: Total indicators seen from the API since the feed started.
      type: Number
    - contextPath: Cypho.FeedStatus.total_created
      description: Total indicators written to Threat Intel.
      type: Number
    - contextPath: Cypho.FeedStatus.cursor
      description: Saved pagination cursor for the next run.
      type: String
    description: Show the current feed state - phase, fetch range, progress, the age
      of the last batch, saved cursor, and the incremental watermark.
  - name: cypho-feed-reset
    arguments:
    - name: confirm
      auto: PREDEFINED
      predefined:
      - "true"
      - "false"
      description: Must be set to true to actually perform the reset.
      defaultValue: "false"
    - name: start_from
      description: Optional. Restart the backfill from this point instead of the instance
        setting. Accepts "1 year", "6 months", "30 days", or an RFC 3339 timestamp.
    description: Clear the saved feed state and restart the historical backfill from
      the beginning. Use with care.
  dockerimage: demisto/python3:3.10.13.87159
  feed: true
  runonce: false
  subtype: python3
