Skip to main content

Cyvest

PyPI Python 3.10+ License: MIT

Cyvest is a Python library and CLI for representing cybersecurity investigations as structured, serializable data.

An investigation is an append-only log of immutable facts — observables, relations, signals, evidence, findings, decisions, tags — plus a report derived from them. No score is ever stored on a fact, which is what makes an investigation auditable, mergeable, and re-scorable under a different policy.

facts  ──(engine + policy)──▶  report

Cyvest 7.1 uses schema_version: "7.1.0" and reads 7.0 documents. Existing 6.x integrations should follow the migration guide — the API changed almost everywhere, and there is no compatibility layer.

Read the documentation for the model, the scoring semantics and the design rationale. This README is a tour of the API.

Installation

uv add cyvest                    # or: pip install cyvest
uvx --from cyvest cyvest --help  # the CLI ships with the package

Quick Start

from cyvest import Cyvest

cv = Cyvest(investigation_name="email-analysis")

url = (
    cv.observable(cv.OBS.URL, "https://phishing-site.com", internal=False)
    .with_ti("virustotal", 8.5, comment="Known phishing site")
)
cv.observable_add_relation(cv.root().key, url.key, cv.REL.EXTRACTION)

evidence = cv.evidence(
    "sandbox_report",
    title="URL detonation report",
    source="internal-sandbox",
    external_id="report-4242",
    content={"verdict": "malicious"},
)

(
    cv.finding("url_analysis", "Analyze suspicious URL")
    .link_observable(url)
    .link_evidence(evidence)
    .with_weight(8.5)
)

print(cv.get_global_score(), cv.get_global_verdict())
cv.display_explanation(url.key)          # and why
cv.io_save_json("investigation.json")

Pass an explicit investigation_id when you want reproducible reports you can diff between runs.


Features

One snippet per capability, picking up where the Quick Start left off. Each link points to the page explaining why it works that way.

Observables and threat intel

url = cv.observable(cv.OBS.URL, "https://bad.com", internal=False)

url.with_ti("virustotal", 8.5)                    # weight → verdict from the score bands
url.with_ti("misp", verdict=cv.VERDICT.SAFE)      # verdict → weight from the policy
url.with_ti("otx", 6.0, confidence=0.4, taxonomies=["phishing"])

url.score, url.verdict, url.threat_intels, url.contributions

Stating either the verdict or the weight is enough; the other is derived. → Concepts

Relations

domain = cv.observable(cv.OBS.DOMAIN, "bad.com")
domain.relate_to(url, cv.REL.EXTRACTION)
domain.relate_to(ip, cv.REL.PIVOT, confidence=0.7)
domain.relate_to(other, cv.REL.RELATED_TO)        # context only, propagates nothing

source is the parent, target the child; the kind implies the direction. → Scoring Model

Auto-link

from cyvest import AutoLink

cv = Cyvest(auto_link=AutoLink())
url = cv.observable(cv.OBS.URL, "hxxp://evil[.]example/login")
cv.observable_get(cv.OBS.DOMAIN, "evil.example")   # derived, linked url → domain by EXTRACTION

A URL contains a host and an e-mail contains a domain: opt in and those edges are drawn on creation, attributed to cyvest.autolink. The child's score then propagates to the parent. → Auto-Link

Findings, evidence and tags

evidence = cv.evidence("sandbox_report", title="Detonation", content={"verdict": "malicious"})

(
    cv.finding("phishing_page", "Credential harvesting page")
    .link_observable(url)
    .link_evidence(evidence)
    .with_weight(8.5)
    .tagged("attack:phishing")                    # tags nest through ':'
)

cv.tag("attack").aggregated_score, cv.tag("attack").descendants()

Concepts

Conclusions

cv.conclusion("analyst_call", "Confirmed phishing", verdict=cv.VERDICT.MALICIOUS)

A conclusion raises the total to the verdict it asserts instead of adding a term — no weight is accepted. → Scoring Model

Decisions

url.allowlist("Corporate sandbox", decided_by="rssi")        # caps the score
ip.blocklist("Confirmed C2", decided_by="soc")               # raises to the policy floor
finding.dismiss("Known false positive", decided_by="alice")  # excluded from the total
finding.confirm("Reviewed and valid", decided_by="bob")
finding.vacate("Stance withdrawn", decided_by="bob")         # back to the computed value

url.allowlisted, finding.dismissed, finding.suppressed_by_decision

A decision bounds a result rather than adding a term, and stays a fact — dated, attributed, mergeable. justification is required.

What a link scores on

cv.finding("rule", "…").link_observable(url)                        # the observable, as it stands

trap = cv.observable_add_threat_intel(url, "proofpoint-trap", verdict=cv.VERDICT.SUSPICIOUS, weight=4.0)
cv.finding("pp-trap-hit", "…").pin(trap)                             # only that feed

cv.finding("seen", "…").link_observable(url, cv.BASIS.NONE)         # documentary, scores nothing

Each finding-to-observable link carries a basis: OBSERVABLE by default, SIGNALS when the link names the intel it scores on, NONE when the edge is there for the graph alone. pin is how a rule reports the verdict it fetched — nothing else landing on that observable, not another feed nor an extracted child, can move it. → Scoring Model

Ingesting external signals

response = fetch_from_virustotal(url_value)      # your connector's job
url.with_ti(Cyvest.io_load_signal(response))

Validated against a published contract, strictly, so a typo fails at the boundary instead of becoming a signal that quietly scores zero. Re-ingesting the same response is a no-op. Producers get the other half — Cyvest.io_dump_signal("virustotal", weight=6.0) builds the JSON payload and fails on their side rather than the consumer's. → External Signals

Policy and engines

from cyvest import DEFAULT_POLICY, Cyvest

policy = DEFAULT_POLICY.model_copy(update={"uphold_floor": 7.0, "version": "strict-v1"})
cv = Cyvest(policy=policy, engine="basic-v1")

cv.reevaluate(policy=policy)                     # replay the same facts differently
Cyvest.ENGINES()                                 # registered engines and their aliases

The report is always re-derived from the facts, never read from the document.

Timeline and statistics

The timeline is projected from the facts you dated — nothing is written to it directly:

from datetime import datetime, timezone
from cyvest import Salience

cv.finding("link-clicked", "`jdoe` opened the landing page", verdict="NOTABLE",
           tactic="initial-access", occurred_at=datetime(2026, 8, 7, 10, 2, tzinfo=timezone.utc))

for entry in cv.timeline(time="asserted", min_salience=Salience.KEY):
    print(entry.when, entry.kind, entry.title, entry.tactic)

cv.statistics()
cv.display_timeline()
cv.display_statistics()

Timeline

Merging and parallel work

report = main.merge_investigation(other)         # idempotent, commutative, associative — header included
report.added, report.superseded                  # what the merge did; EngineMismatchError if scales differ

shared = main.shared_context()

def worker(shared):
    with shared.task() as cv:
        cv.observable(cv.OBS.EMAIL, "sender@example.com")

snapshot = shared.snapshot()                     # a frozen Cyvest over the union
print(snapshot.get_global_score(), snapshot.get_global_verdict())

Reconciling is a union of facts, so arrival order does not matter and reconciling twice is harmless. Reads go through a snapshot, so several reads of one task cannot straddle two states. → Shared Investigation Context

LangChain agents

from langchain.agents import create_agent
from cyvest.integrations.langchain import CyvestMiddleware, INVESTIGATION_KEY

agent = create_agent(model, tools=my_tools, middleware=[CyvestMiddleware(root_data={"case": "IR-2431"})])
state = agent.invoke({"messages": [{"role": "user", "content": "Triage this alert"}]})
state[INVESTIGATION_KEY]                          # the serialized investigation

pip install 'cyvest[langchain]'. The investigation lives in the agent state and is merged by union; the model reads it through cyvest_report/cyvest_explain, writes it through one batched, all-or-nothing cyvest_record, and sees the recomputed report in its system prompt on every turn. → LangChain Integration

Comparing investigations

from cyvest import ExpectedResult, compare_investigations

diffs = compare_investigations(
    actual,
    result_expected=[ExpectedResult(rule_id="phishing-page", score=">= 3.0")],
)

Tolerance rules express a band, so a test survives a policy tweak that shifts magnitudes without changing conclusions. → Comparing Investigations

Serialization

cv.io_save_json("investigation.json")
cv.io_save_markdown("report.md")
data = cv.io_to_dict()

cv = Cyvest.io_load_json("investigation.json")
cv = Cyvest.io_load_json("v6-document.json", migrate=True)

A versioned JSON schema, generated TypeScript types, and migration from 5.x and 6.x.

Rich output

cv.display_summary(show_graph=True)
cv.display_explanation(url.key)                  # every number, with the terms behind it
cv.display_diff(expected)                        # or result_expected=[...]

Extracting IOCs

from cyvest.extract import defang, extract_all, extract_from_url, observables_to_markdown

observables = extract_all("Contact evil@example.com via hxxps://bad[.]com")
markdown = observables_to_markdown(observables)
safe_text = defang("https://malware.com")        # -> hxxps://malware[.]com

Observable Extraction


CLI

# Inspect
cyvest show investigation.json --stats
cyvest stats investigation.json --detailed
cyvest explain investigation.json obs:url:https://phishing-site.com
cyvest timeline investigation.json --key-only

# Compose
cyvest merge inv1.json inv2.json inv3.json -o merged.json
cyvest diff actual.json expected.json --rules tolerances.json

# Convert
cyvest export investigation.json -o report.md -f markdown
cyvest migrate old.json -o new.json              # detects 5.x or 6.x
cyvest schema -o ./schema/cyvest.schema.json
cyvest schema --which signal -o ./schema/cyvest.signal.schema.json

# Evaluation
cyvest engines
cyvest policy show investigation.json
cyvest show investigation.json --engine basic-v1

# Extraction
echo "IP: 192[.]168[.]1[.]1, URL: hxxps://evil[.]com" | cyvest extract
cyvest extract report.txt -t url -t ip -t hash -f json -o iocs.json
cyvest extract --from-url https://example.com/ioc-feed.txt
cyvest extract -R < defanged_iocs.txt            # keep the defanged form

The Python side always re-derives the report from the facts on load, so a stale or hand-edited report block never influences what the CLI prints; --engine chooses which engine does it.

Examples

File Shows
01_email_basic.py a minimal email investigation
02_urls_and_ips.py observables, relations, threat intel
03_merge_demo.py merging investigations from several sources
04_email.py a realistic phishing case
05_graph_dataset.py a dataset for the graph visualisation
06_compare_investigations.py diffing and tolerance rules
07_conclusion.py conclusions and analyst calls
08_pinned_finding.py pinning a finding to the intel it fetched
09_langchain_agent.py an agent keeping its investigation in Cyvest (offline, scripted model)

JavaScript packages

A PNPM workspace under js/:

  • @cyvest/cyvest-js — types, schema validation and query helpers. Read-only by construction: it never recomputes a score, it reads the report the document carries.
  • @cyvest/cyvest-vis — React component for the force-directed observable graph (Cytoscape + d3-force).
  • @cyvest/cyvest-app — Vite demo bundling the packages with sample investigations.

JavaScript Packages

Development

git clone https://github.com/PakitoSec/cyvest.git && cd cyvest
uv sync --all-groups

uv run pytest -q                   # tests
uv run pytest --cov=cyvest         # with coverage
uv run ruff format src tests       # format
uv run ruff check src tests        # lint

bash scripts/generate.sh           # schema, TS types, fixtures and JS builds
uv run --group docs mkdocs serve   # docs preview

Contributing

License

MIT — see the LICENSE file.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

cyvest-7.1.0.tar.gz (131.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

cyvest-7.1.0-py3-none-any.whl (158.7 kB view details)

Uploaded Python 3

File details

Details for the file cyvest-7.1.0.tar.gz.

File metadata

  • Download URL: cyvest-7.1.0.tar.gz
  • Upload date:
  • Size: 131.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for cyvest-7.1.0.tar.gz
Algorithm Hash digest
SHA256 f188c0fea8c526af73981b76c8b0f2cb06ca0911d39c42745c9f5d832bcb4906
MD5 5259e0844517f0a1ce725746ffb4fb35
BLAKE2b-256 1a6f39cbd92c631be23ca9e599c8d151e573761574c727b43a2d9b2c36210922

See more details on using hashes here.

Provenance

The following attestation bundles were made for cyvest-7.1.0.tar.gz:

Publisher: ci.yml on PakitoSec/cyvest

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file cyvest-7.1.0-py3-none-any.whl.

File metadata

  • Download URL: cyvest-7.1.0-py3-none-any.whl
  • Upload date:
  • Size: 158.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for cyvest-7.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 1fa756c0b93ae00dd01f224304b7bb3f4b55547eea1b628ba229fc7ae8d614d6
MD5 cfd8f06833c59cf055d22a328352f68a
BLAKE2b-256 dd5ea7708c441a5fb085d67e9a243054f8b58c9ede3983b868d8d0a58154ccfc

See more details on using hashes here.

Provenance

The following attestation bundles were made for cyvest-7.1.0-py3-none-any.whl:

Publisher: ci.yml on PakitoSec/cyvest

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

7.2.0

2 files

This release

7.1.0 This release

2 files

7.0.0

2 files

6.1.2

2 files

6.1.1

2 files

6.1.0

2 files

6.0.0

2 files

5.4.1

2 files

5.4.0

2 files

5.3.3

2 files

5.3.2

2 files

5.3.1

2 files

5.3.0

2 files

5.2.1

2 files

5.2.0

2 files

5.1.4

2 files

5.1.3

2 files

5.1.2

2 files

5.1.1

2 files

5.1.0

2 files

5.0.4

2 files

5.0.3

2 files

5.0.2

2 files

5.0.0

2 files

4.4.1

2 files

4.4.0

2 files

4.3.0

2 files

4.2.0

2 files

4.1.0

2 files

4.0.0

2 files

3.2.0

2 files

3.1.0

2 files

3.0.1

2 files

3.0.0

2 files

2.0.1

2 files

2.0.0

2 files

1.0.5

2 files

1.0.4

2 files

1.0.3

2 files

1.0.2

2 files

1.0.1

2 files

1.0.0

2 files

0.2.0

2 files

0.1.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page