qarbaal
Extract, normalize, validate, and export indicators of compromise from text and files.
Documentation: https://ahur4.github.io/Qarbaal/
qarbaal reads reports, logs, and message files on your machine and returns classified indicators. It refangs defanged text, records where each value came from, and writes CSV, JSON, STIX 2.1, MISP, OpenIOC, and network rules. Results are pydantic models, and indicator types are IOCType members. File readers beyond the standard library are optional extras. It does not call an enrichment API.
Installation
Python 3.10 or newer.
pip install qarbaal
The base install depends on pydantic. HTML, PDF, Word, YAML, and packet-capture readers stay optional.
Installing optional extras
Text, JSON, CSV, XML, EML, and MBOX are read with the standard library. The other readers are extras, so a text-only install does not pull them in.
One extra:
pip install "qarbaal[html]"
Several at once:
pip install "qarbaal[html,pdf,docx]"
Every optional extra:
pip install "qarbaal[all]"
| Input | Extra |
|---|---|
| Text, JSON, CSV, XML, EML, MBOX | none |
| HTML | html |
pdf |
|
| DOCX | docx |
| YAML | yaml |
| PCAP | pcap |
A missing extra raises OptionalDependencyError and names the install command. Extraction results are pydantic models, and indicator.type is an IOCType member.
Quickstart
from qarbaal import IOCExtractor, IOCType, extract
report = """
From: support[at]corp-it-desk[.]com
Lure: hxxps://login[.]corp-it-desk[.]com/session
Beacon: 185.220.101.42
MD5: d41d8cd98f00b204e9800998ecf8427e
"""
result = extract(report)
for indicator in result:
print(indicator.type, indicator.value, indicator.tags, indicator.line)
extractor = IOCExtractor(
extract_types=[IOCType.IPV4, IOCType.DOMAIN, IOCType.URL, IOCType.EMAIL],
normalize=True,
validate=True,
deduplicate=True,
)
same = extractor.extract(report, source="ticket-14")
extract() uses the default type set. File paths, mutex names, and User-Agent strings stay out of that set until you ask for them with extract_types or "all".
A synthetic incident is included:
from qarbaal import SAMPLE, extract
print(extract(SAMPLE).stats.counts)
What you get back
Each indicator keeps three strings:
| Field | Meaning |
|---|---|
value |
First-seen sieve form. Hashes are lower case, CVE IDs are upper case. |
raw |
Exact slice of the source, which may still be defanged. |
normalized |
Canonical form used for the fingerprint. |
It also carries:
- Provenance on every sighting: source label, line, character offset, and page number for PDF.
- A SHA-256 fingerprint of the type and the normalized value.
raw,value, andnormalizedare all kept. - Local enrichment only. Address flags, a best-effort domain split, and URL parts are computed on the machine. Nothing is sent to a network service.
- Deduplication and cross-source correlation.
occurrencescounts sightings.Extraction.shared()lists indicators that appear in more than one named source. - Custom
PatternSpecpatterns, allow and deny globs, and custom validators. - Streaming through
extract_iterfor large text, with offsets preserved across chunks. - Noise tags, a structural confidence heuristic, and pivot links you open yourself.
Exports are CSV, JSON, JSONL, a defanged list, STIX 2.1, MISP, OpenIOC, and Suricata and Snort rules for addresses, domains, and URLs. Pass path and filename to write a file and receive an ExportFile.
Using qarbaal in Python
These calls use the public API. Pass IOCType members such as IOCType.IPV4 and IOCType.URL. extract returns a pydantic Extraction, and each indicator is a pydantic model. Nothing below contacts a network service.
Quick extraction
extract uses the default type set. Paths, mutex names, and User-Agent strings stay out until you ask for them.
from qarbaal import IOCType, extract
text = "Beacon 185.220.101.42 and hxxps://login[.]evil[.]example/a"
result = extract(text)
for indicator in result.indicators:
print(indicator.type, indicator.value)
print(indicator.raw, indicator.normalized)
print(indicator.confidence, indicator.fingerprint)
print(indicator.sources, indicator.occurrences, indicator.valid)
print(indicator.validation_notes)
for sighting in indicator.sightings:
print(
sighting.source,
sighting.line,
sighting.offset,
sighting.end_offset,
sighting.page,
)
print(indicator.enrichment)
ips = result.get(IOCType.IPV4)
print(result.values(IOCType.IPV4))
print(
result.stats.counts,
result.stats.total,
result.stats.sightings,
result.stats.input_characters,
)
result is an Extraction. result.indicators is the deduplicated tuple in export order. Iterating result walks that same tuple.
result.get(IOCType.IPV4) returns the indicators of one type. result.values(IOCType.IPV4) returns their sieve strings. indicator.type is an IOCType member. result.stats is an ExtractionStats: counts (unique indicators per type), total, sightings, and input_characters, plus truncated, match_limit_hit, and sources. Call model_dump() when you want a dictionary.
Each Indicator keeps type, value (first-seen sieve form), raw (the original slice, which may still be defanged), and normalized. fingerprint is the SHA-256 hex digest of the type and the normalized value. sources is the distinct source labels. occurrences is the number of sightings. Each Sighting has source, line, offset, end_offset, and page (None when the file has no pages). valid is True, False, or None when validation was skipped. validation_notes lists reason codes.
Configured extractor
from qarbaal import IOCExtractor
text = "Beacon 185.220.101.42 and https://login.evil.example/a and 10.1.1.1"
extractor = IOCExtractor(
normalize=True,
validate=True,
deduplicate=True,
hide_private=True,
drop_invalid=False,
allow=["185.220.*", "*.example"],
deny=["10.*"],
)
result = extractor.extract(text, source="ticket-14")
normalize keeps a canonical value and applies NFKC before the scan. validate records structural checks and the confidence heuristic. deduplicate merges indicators that share a fingerprint. hide_private drops tagged IPv4, IPv6, and CIDR indicators. drop_invalid drops indicators whose valid is False.
allow and deny are globs, compared case-insensitively to value, normalized, and raw. When allow is set, a value must match one pattern. A deny match always drops the indicator. source is the label stored on every sighting.
Enrichment
Enrichment is computed locally. as_dict() omits empty fields.
from qarbaal import IOCType, extract
ip = extract("8.8.8.8").get(IOCType.IPV4)[0]
print(ip.enrichment.as_dict())
url = extract("https://login.evil.example/a").get(IOCType.URL)[0]
print(url.enrichment.as_dict())
domain = extract("login.evil.example").get(IOCType.DOMAIN)[0]
print(domain.enrichment.as_dict())
An IP dictionary includes ip_version, is_private, is_global, is_multicast, is_reserved, is_loopback, is_link_local, and is_cgnat. A domain dictionary includes tld, registered_domain, and subdomain. A URL dictionary adds scheme, port, path, and host. The registered-domain split is a best-effort heuristic, not a Public Suffix List.
Cross-source correlation
extract_many accepts a mapping or a sequence of (name, text) pairs. Indicators seen in two or more sources are shared(). occurrences counts every sighting.
from qarbaal import IOCExtractor, merge
extractor = IOCExtractor()
correlated = extractor.extract_many(
{
"feedA": "C2 185.220.101.42",
"feedB": "also 185.220.101.42 and 1.2.3.4",
}
)
for indicator in correlated.shared():
print(indicator.value, indicator.occurrences, indicator.sources)
res1 = extractor.extract("C2 185.220.101.42", source="feedA")
res2 = extractor.extract("also 185.220.101.42 and 1.2.3.4", source="feedB")
merged = merge([res1, res2])
merge combines Extraction objects that were produced separately. The first sieve value wins. Use extract_many when you still have the text.
Streaming large inputs
extract_iter reads an iterable of strings and keeps only an overlap window between chunks, so a large file does not have to sit in memory as one string. Offsets and line numbers are counted from the start of the stream.
from qarbaal import IOCExtractor
def chunks():
yield "first 185.220.101.42\n"
yield "second 1.1.1.1\n"
extractor = IOCExtractor()
streamed = extractor.extract_iter(chunks(), source="huge.log")
Plain-text extract_path uses this path. Keep chunk_overlap at least as long as the longest token you care about. The default is 8192.
Custom indicator types
PatternSpec adds a pattern for an existing IOCType. Group 1 is the value when the expression has a group. Otherwise the whole match is the value. folds is lower, upper, or preserve. The type's own checks still apply. A mutex pattern is included below because mutexes are opt-in, and this pattern enables that type.
A validator receives the indicator and returns False to mark it invalid. Validation must be on, which is the default. The note is rejected-by: plus the function name. drop_invalid=True then leaves that indicator out of the result.
from qarbaal import IOCExtractor, IOCType, PatternSpec
def public_only(indicator):
return not indicator.tags
extractor = IOCExtractor(
custom_patterns=[
PatternSpec(type=IOCType.MUTEX, regex=r"mutex\{([^}]+)\}"),
],
validators=[public_only],
)
result = extractor.extract("saw mutex{WinDefend} and 10.0.0.1")
print(result.values(IOCType.MUTEX))
Files
load_document turns a file into text and, for PDF, page spans. extract_path loads and extracts in one call. The source label is the path.
from qarbaal import IOCExtractor, load_document
document = load_document("notes.json")
result = IOCExtractor().extract(
document.text,
source=document.source,
pages=document.pages,
)
same = IOCExtractor().extract_path("notes.txt")
Text, JSON, CSV, XML, EML, and MBOX need no extra. HTML, PDF, DOCX, YAML, and PCAP need the matching extra. A missing extra raises OptionalDependencyError with the install command.
Exports
Each exporter takes an Extraction. Live values are kept, except to_defanged_list, which rewrites them for a report. With no destination, the function returns the serialized text. Pass path (a directory) and filename (a file name) to write that text to disk. The directory is created when it is missing. Omit filename and the name is chosen for the format (qarbaal.csv, qarbaal.json, qarbaal.stix.json, and so on). Omit path and the file is written in the current working directory.
A file write returns an ExportFile: path (absolute), filename, size in bytes, format, count, and sha256.
from qarbaal import (
extract,
to_csv,
to_defanged_list,
to_json,
to_jsonl,
to_misp,
to_openioc,
to_snort,
to_stix,
to_suricata,
)
result = extract("8.8.8.8 https://evil.example/a")
print(to_defanged_list(result))
written = to_stix(result, path="out", filename="bundle.stix.json")
print(written.path, written.size, written.sha256)
print(to_csv(result, path="out"))
to_csv, to_json, to_jsonl, to_misp, to_openioc, to_suricata, and to_snort take the same extraction and the same path and filename arguments. Suricata and Snort rules are emitted only for addresses, CIDR ranges, domains, and URLs. For those two formats, count is the number of rules. For STIX, count is the number of objects in the bundle.
Pydantic
Extraction, Indicator, Sighting, Enrichment, ExtractionStats, and ExportFile are pydantic models. indicator.type is an IOCType member. model_dump() and model_dump(mode="json") serialize them. model_dump(mode="json") writes the type as its short key, such as "ip".
Further reading
Metadata
Release files for qarbaal 0.2.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| qarbaal-0.2.1.tar.gz | 64.7 kB | Without provenance | Details for qarbaal-0.2.1.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| qarbaal-0.2.1-py3-none-any.whl | Python 3 | none | any | Details for qarbaal-0.2.1-py3-none-any.whl |
Total release size: 118.6 kB
Release files / qarbaal-0.2.1.tar.gz
| Download URL | qarbaal-0.2.1.tar.gz |
|---|---|
| Size | 64.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
68f51b8709b7ea54a971a0a67c677db46a206a3e9a5f25389963d8a052f3fa33
|
|
BLAKE2b-256 checksum How to use checksums |
0f0b44b9ca192455d077c9eb18220a93a7ad684cd66f15aaa978b72a255992f0
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.5
|
Release files / qarbaal-0.2.1-py3-none-any.whl
| Download URL | qarbaal-0.2.1-py3-none-any.whl |
|---|---|
| Size | 53.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
17249ea107e14513b9eda0a9c021b11aded96d4b3b0507558455c418d9cc371b
|
|
BLAKE2b-256 checksum How to use checksums |
f29f2f76dba913c1724e94a300263f17c17f01aa4e68862d7610a08a4fe10b64
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.5
|
Security for qarbaal 0.2.1
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.