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
42 changes: 22 additions & 20 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,28 +1,37 @@
.DS_Store
**/venv/*
**/venv3/*
# build
__pycache__
**/dist/*
**/build/*
**/pkg_build/*
**/.ruff_cache/*
*.log
*.log.*
*.csv
.tox
rfalerts_*.json
rfplaybookalerts_*.json
dist/
.vscode
*.pyc
*.egg-info
*.tar.gz
*.tgz
site/

# client
.DS_Store
.vscode
.idea/


# tets and coverage
**/.ruff_cache/*
*.log
*.log.*
*.csv
result.xml
.coverage
tests/stix2/tests
tests/output/*
virt/
alerts/
attachments/
rules/
risklists/
bundles/


# docs
docs/examples/classic_alerts/alerts
docs/examples/playbook_alerts/alerts
docs/examples/analyst_notes/attachments
Expand All @@ -31,11 +40,4 @@ docs/examples/enrich/enrich
docs/examples/risklists/risklists
docs/examples/stix2/bundles

alerts/
attachments/
rules/
risklists/
bundles/


site/
4 changes: 4 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## v2.8.0 - 2026-05-22

- Added support for Links API via the `LinksMgr`.

## v2.7.0 - 2026-05-01

- Added support for Threat Map and Malware Map via the `ThreatMapMgr`.
Expand Down
1 change: 1 addition & 0 deletions docs/api/links/errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
::: psengine.links.errors
1 change: 1 addition & 0 deletions docs/api/links/links.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
::: psengine.links.links
8 changes: 8 additions & 0 deletions docs/api/links/links_mgr.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
::: psengine.links.links_mgr.LinksMgr
options:
members:
- __init__
- list_sections
- list_events
- list_entity_types
- search
1 change: 1 addition & 0 deletions docs/api/links/models.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
::: psengine.links.models
Empty file added docs/examples/links/__init__.py
Empty file.
16 changes: 16 additions & 0 deletions docs/examples/links/example_1.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from psengine.links import LinksMgr

mgr = LinksMgr()

results = mgr.search(entities='QCwdoU')

for result in results:
if result.error:
print(f'Failed: {result.error.message}')
continue

entity = result.entity
print(f'Entity: {entity.name}')

for link in result.links[:5]:
print(f' -> {link.name} source: {link.source}')
20 changes: 20 additions & 0 deletions docs/examples/links/example_2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from psengine.links import LinksMgr

mgr = LinksMgr()

results = mgr.search(
entities='I60vfZ',
sources='technical',
entity_types='type:Malware',
timeframe='-90d',
search_scope='small',
per_entity_type=50,
)

for result in results:
if result.error:
print(f'Failed: {result.error.message}')
continue

for link in result.links:
print(f'{link.name} ({link.type_})')
32 changes: 32 additions & 0 deletions docs/examples/links/example_3.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from psengine.links import LinksMgr

mgr = LinksMgr()

results = mgr.search(entities=['QCwdoU'])

for result in results:
if result.error:
print(f'Failed: {result.error.message}')
continue

print(f'Entity: {result.entity.name}')

print('\nIOCs grouped by type:')
for ioc_type, iocs in result.iocs().items():
print(f' {ioc_type}: {len(iocs)}')
for ioc in iocs[:3]:
print(
f' - {ioc.name} score:{ioc.risk_score}'
)

print('\nTTPs:')
for ttp in result.ttps()[:5]:
print(f' - {ttp.name} ({ttp.display_name})')

print('\nMalwares:')
for malware in result.malwares()[:5]:
print(f' - {malware.name}')

print('\nThreat actors:')
for threat_actor in result.threat_actors()[:5]:
print(f' - {threat_actor.name}')
96 changes: 96 additions & 0 deletions docs/modules/links.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
## Introduction

The `LinksMgr` class of the `links` module allows you to search for technically validated relationships between threat intelligence entities in the Recorded Future Intelligence Cloud — connections established through sandbox analysis, infrastructure analysis, network traffic analysis, and Insikt Group research.

See the [**API Reference**](../api/links/links_mgr.md) for internal details of the module.

## Notes

The `search` method expects Recorded Future entity IDs (for example, `QCwdoU`), not entity names. If you only have a name, use the `entity_match` module first to resolve the ID.

The response is batched: you get one result per input entity. A specific entity can fail while others succeed, so always check `result.error` before iterating over `result.links`.

For filters such as sections, events, and entity types, use the metadata methods (`list_sections`, `list_events`, and `list_entity_types`) to retrieve valid IDs before calling `search`.

## Examples

{! modules/_includes/examples_warning.md !}

#### 1: Search links for an entity and handle per-entity errors

In this example, we call `search` with a single entity ID. For each result, we first check `result.error`. If there is no error, we print the source entity and the first 5 linked entities returned by the API.

```python
--8<-- "docs/examples/links/example_1.py"
```

The output will be:

```
Entity: Lazarus Group
-> CVE-2022-47966 source: insikt
-> 24988feb1b38f400069acec4514aa4deea3f6ca8ceb5296f54926e2b22af1e5a source: insikt
-> 36db27f5eb3343cfc72d261d78da44957a49cb6731acb50a96ea5694f4d616c5 source: insikt
-> ffec6e6d4e314f64f5d31c62024252abde7f77acdd63991cb16923ff17828885 source: insikt
-> 3e5fd9acdab438ffc8b2cce48c91679d3f980d08f9dea47d5e1039d352cd64fb source: insikt
```


#### 2: Filter link results and apply limits

In this example, we pass filter and limit arguments directly to `search` (for example `sources`, `entity_types`, `timeframe`, `search_scope`, and `per_entity_type`) to narrow results to technical malware links seen in the last 90 days and cap result size per entity type.

```python
--8<-- "docs/examples/links/example_2.py"
```

#### 3: Extract IOCs, Threat Actors and Malwares from links

Each item returned by `LinksMgr.search()` is an `EntityLinks` object. After checking `result.error`, you can use helper methods to extract common subsets from `result.links`:

- `result.iocs()`: returns linked IOCs grouped by IOC type (`type:InternetDomainName`, `type:CyberVulnerability`, `type:IpAddress`, `type:Hash`, `type:Url`). Each IOC item includes `id`, `type`, `name`, `risk_score`, and `source`.
- `result.ttps()`: returns linked MITRE ATT&CK techniques (`type:MitreAttackIdentifier`). Each item includes `id`, `type`, `name`, `display_name`, and `source`.
- `result.malwares()`: returns linked malware entities (`type:Malware`) with `id`, `type`, `name`, and `source`.
- `result.threat_actors()`: returns linked organizations (`type:Organization`) that are marked as threat actors through the `threat_actor` attribute.

```python
--8<-- "docs/examples/links/example_3.py"
```

This will output:

```
Entity: Lazarus Group

IOCs grouped by type:
type:CyberVulnerability: 5
- CVE-2022-47966 score:99
- CVE-2019-3396 score:89
- CVE-2022-0609 score:79
type:Hash: 1273
- c8706a586afa880fbf23ce662b7fc2925fd0384b8cde0a40f3c00a182c5a3d06 score:89
- 9ab05d771d6face27502052af8e3a945ad66e3c6726a2dda637fa12641e2bca2 score:89
- 084f904249f8655e925b4b330426df6b979cd3f630f7765dac405f2144755738 score:89
type:InternetDomainName: 144
- calendly.live score:74
- pypilibrary.com score:72
- test-wolf.com score:69
type:IpAddress: 29
- 172.96.137.224 score:55
- 103.231.75.101 score:38
- 23.27.140.49 score:32

TTPs:
- T1082 (T1082 (System Information Discovery))
- T1071 (T1071 (Application Layer Protocol))
- TA0010 (TA0010 (Exfiltration))
- T1027 (T1027 (Obfuscated Files or Information))
- T1497.001 (T1497.001 (Virtualization/Sandbox Evasion: System Checks))

Malwares:
- CIPHERROCKET
- QuiteRAT
- PondRAT
- Trickbot
- NukeSped
```
6 changes: 6 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ nav:
- Entity Match: modules/entity_match.md
- Fusion: modules/fusion.md
- Identity: modules/identity.md
- Links: modules/links.md
- Malware Intelligence: modules/malware_intel.md
- Playbook Alerts: modules/playbook_alerts.md
- Risk History: modules/risk_history.md
Expand Down Expand Up @@ -191,6 +192,11 @@ nav:
- ADT: api/identity/identity.md
- Constants: api/identity/constants.md
- Errors: api/identity/errors.md
- Links:
- Manager: api/links/links_mgr.md
- ADT: api/links/links.md
- Models: api/links/models.md
- Errors: api/links/errors.md
- Logger:
- Logger: api/logger/rf_logger.md
- Constants: api/logger/constants.md
Expand Down
11 changes: 5 additions & 6 deletions psengine/analyst_notes/note_mgr.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,11 @@
class AnalystNoteMgr:
"""Manages requests for Recorded Future analyst notes."""

def __init__(self, rf_token: str = None):
"""Initializes the `AnalystNoteMgr` object.

Args:
rf_token (str, optional): Recorded Future API token.
"""
def __init__(
self,
rf_token: Annotated[str | None, Doc('Recorded Future API token.')] = None,
):
"""Initializes the `AnalystNoteMgr` object."""
self.log = logging.getLogger(__name__)
self.rf_client = RFClient(api_token=rf_token) if rf_token else RFClient()

Expand Down
11 changes: 5 additions & 6 deletions psengine/asi/asi_mgr.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,12 +95,11 @@ def _validate_exposure_score_range(v: tuple[int, int]) -> tuple[int, int]:
class AttackSurfaceMgr:
"""Manages requests for Recorded Future SecurityTrails (ASI) API."""

def __init__(self, api_token: str = None):
"""Initializes the `AttackSurfaceMgr` object.

Args:
api_token (str, optional): ASI API token.
"""
def __init__(
self,
api_token: Annotated[str | None, Doc('ASI API token.')] = None,
):
"""Initializes the `AttackSurfaceMgr` object."""
self.log = logging.getLogger(__name__)
self.asi_client = ASIClient(api_token=api_token) if api_token else ASIClient()

Expand Down
11 changes: 5 additions & 6 deletions psengine/classic_alerts/classic_alert_mgr.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,11 @@
class ClassicAlertMgr:
"""Alert Manager for Classic Alert (v3) API."""

def __init__(self, rf_token: str = None):
"""Initializes the ClassicAlertMgr object.

Args:
rf_token (str, optional): Recorded Future API token.
"""
def __init__(
self,
rf_token: Annotated[str | None, Doc('Recorded Future API token.')] = None,
):
"""Initializes the ClassicAlertMgr object."""
self.log = logging.getLogger(__name__)
self.rf_client = RFClient(api_token=rf_token) if rf_token else RFClient()

Expand Down
11 changes: 5 additions & 6 deletions psengine/collective_insights/collective_insights.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,11 @@
class CollectiveInsights:
"""Class for interacting with the Recorded Future Collective Insights API."""

def __init__(self, rf_token: str = None):
"""Initializes the CollectiveInsights object.

Args:
rf_token (str, optional): Recorded Future API token.
"""
def __init__(
self,
rf_token: Annotated[str | None, Doc('Recorded Future API token.')] = None,
):
"""Initializes the CollectiveInsights object."""
self.log = logging.getLogger(__name__)
self.rf_client = RFClient(api_token=rf_token) if rf_token else RFClient()

Expand Down
10 changes: 10 additions & 0 deletions psengine/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,16 @@
EP_RISK_HISTORY_BASE = BASE_URL + '/risk'
EP_RISK_HISTORY = EP_RISK_HISTORY_BASE + '/history'

###############################################################################
# Links API Endpoints
###############################################################################
LINKS_BASE_URL = f'{BASE_URL}/links'
LINKS_METADATA_URL = f'{LINKS_BASE_URL}/metadata'
EP_LINKS_SEARCH = f'{LINKS_BASE_URL}/search'
EP_LINKS_METADATA_SECTIONS = f'{LINKS_METADATA_URL}/sections'
EP_LINKS_METADATA_EVENTS = f'{LINKS_METADATA_URL}/events'
EP_LINKS_METADATA_ENTITIES = f'{LINKS_METADATA_URL}/entities'

################################################################################
# Attack Surface Intelligence API Endpoints
################################################################################
Expand Down
11 changes: 5 additions & 6 deletions psengine/fusion/fusion_mgr.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,11 @@
class FusionMgr:
"""Manages requests for Recorded Future Fusion files."""

def __init__(self, rf_token: str = None):
"""Initializes the `FusionMgr` object.

Args:
rf_token (str, optional): Recorded Future API token.
"""
def __init__(
self,
rf_token: Annotated[str | None, Doc('Recorded Future API token.')] = None,
):
"""Initializes the `FusionMgr` object."""
self.log = logging.getLogger(__name__)
self.rf_client = RFClient(api_token=rf_token) if rf_token else RFClient()

Expand Down
9 changes: 9 additions & 0 deletions psengine/helpers/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,15 @@ def convert_relative_time(
else input_time
)

@staticmethod
def is_rel_time_valid(
input_time: Annotated[str | None, Doc("Relative time string, e.g., '7d', '3h'.")],
):
"""Check that a relative time like `-3d` is valid."""
if input_time is not None and not TimeHelpers.is_rel_time_valid(input_time):
raise ValueError(f'Invalid relative time: {input_time}')
return input_time

@staticmethod
def check_uhash_prefix(
value: Annotated[str | list, Doc('String or list of strings to check for uhash prefix.')],
Expand Down
Loading
Loading