iocforge-cli 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- iocforge_cli-0.1.0/.github/workflows/ci.yml +16 -0
- iocforge_cli-0.1.0/.github/workflows/publish.yml +43 -0
- iocforge_cli-0.1.0/.gitignore +7 -0
- iocforge_cli-0.1.0/LICENSE +38 -0
- iocforge_cli-0.1.0/PKG-INFO +73 -0
- iocforge_cli-0.1.0/README.md +55 -0
- iocforge_cli-0.1.0/pyproject.toml +29 -0
- iocforge_cli-0.1.0/src/iocforge/__init__.py +5 -0
- iocforge_cli-0.1.0/src/iocforge/__main__.py +3 -0
- iocforge_cli-0.1.0/src/iocforge/cli.py +40 -0
- iocforge_cli-0.1.0/src/iocforge/core.py +140 -0
- iocforge_cli-0.1.0/tests/test_core.py +48 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
on:
|
|
3
|
+
push:
|
|
4
|
+
pull_request:
|
|
5
|
+
jobs:
|
|
6
|
+
test:
|
|
7
|
+
runs-on: ubuntu-latest
|
|
8
|
+
steps:
|
|
9
|
+
- uses: actions/checkout@v4
|
|
10
|
+
- uses: actions/setup-python@v5
|
|
11
|
+
with:
|
|
12
|
+
python-version: '3.12'
|
|
13
|
+
- run: python -m pip install -e '.[dev]'
|
|
14
|
+
- run: ruff check .
|
|
15
|
+
- run: python -m pytest -q
|
|
16
|
+
- run: python -m build
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
publish:
|
|
13
|
+
name: Build and publish
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
environment: pypi
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
- uses: actions/setup-python@v5
|
|
19
|
+
with:
|
|
20
|
+
python-version: "3.12"
|
|
21
|
+
- name: Install release tools
|
|
22
|
+
run: python -m pip install --upgrade build twine
|
|
23
|
+
- name: Confirm the tag matches the package version
|
|
24
|
+
env:
|
|
25
|
+
RELEASE_TAG: ${{ github.ref_name }}
|
|
26
|
+
run: |
|
|
27
|
+
python - <<'PY'
|
|
28
|
+
import os
|
|
29
|
+
import tomllib
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
version = tomllib.loads(Path("pyproject.toml").read_text())["project"]["version"]
|
|
33
|
+
if os.environ["RELEASE_TAG"] != f"v{version}":
|
|
34
|
+
raise SystemExit("Release tag does not match the package version.")
|
|
35
|
+
PY
|
|
36
|
+
- name: Build distributions
|
|
37
|
+
run: python -m build
|
|
38
|
+
- name: Validate distributions
|
|
39
|
+
run: twine check dist/*
|
|
40
|
+
- name: Publish to PyPI
|
|
41
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
42
|
+
with:
|
|
43
|
+
password: ${{ secrets.PYPI_API_TOKEN }}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
Proprietary Software License — Free Use, No Modification
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Noir Stack LLC. All rights reserved.
|
|
4
|
+
|
|
5
|
+
## 1. Grant of License
|
|
6
|
+
|
|
7
|
+
The copyright holder grants users a non-exclusive, worldwide, royalty-free license to download, install, and use this software for personal, educational, research, and commercial purposes, subject to the conditions below.
|
|
8
|
+
|
|
9
|
+
## 2. Restrictions
|
|
10
|
+
|
|
11
|
+
Users may not, without prior written permission from the copyright holder:
|
|
12
|
+
|
|
13
|
+
- Modify, adapt, translate, or create derivative works based on the software.
|
|
14
|
+
- Redistribute, publish, sell, sublicense, or repackage the software.
|
|
15
|
+
- Remove or alter copyright, attribution, or license notices.
|
|
16
|
+
- Claim ownership or authorship of the software.
|
|
17
|
+
|
|
18
|
+
## 3. Ownership
|
|
19
|
+
|
|
20
|
+
All rights, title, and interest in the software, including its source code and associated intellectual property, remain with the copyright holder.
|
|
21
|
+
|
|
22
|
+
No ownership rights are transferred under this license.
|
|
23
|
+
|
|
24
|
+
## 4. No Warranty
|
|
25
|
+
|
|
26
|
+
The software is provided "AS IS", without warranty of any kind, express or implied, to the extent permitted by applicable law.
|
|
27
|
+
|
|
28
|
+
## 5. Limitation of Liability
|
|
29
|
+
|
|
30
|
+
To the extent permitted by applicable law, the copyright holder shall not be liable for damages arising from the use or inability to use the software.
|
|
31
|
+
|
|
32
|
+
## 6. Termination
|
|
33
|
+
|
|
34
|
+
Permission to use the software terminates upon a material violation of this license, subject to applicable law.
|
|
35
|
+
|
|
36
|
+
## 7. Additional Permissions
|
|
37
|
+
|
|
38
|
+
Any permissions not expressly granted must be obtained in writing from the copyright holder.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: iocforge-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Offline threat indicator extraction, normalization and reporting
|
|
5
|
+
Author: Noir Stack LLC
|
|
6
|
+
License: Proprietary Software License — Free Use, No Modification
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: dfir,ioc,osint,stix,threat-intelligence
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
11
|
+
Classifier: Topic :: Security
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
16
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# IOCForge
|
|
20
|
+
|
|
21
|
+
Offline threat indicator extraction and normalization for incident responders, threat-intelligence analysts, and OSINT workflows.
|
|
22
|
+
|
|
23
|
+
**Features:** extracts IPv4 addresses, domains, HTTP(S) URLs, MD5, SHA-1, and SHA-256 hashes; refangs common `hxxp`, `[.]`, `(.)`, and `[:]` notation; deduplicates; exports JSON, CSV, and basic STIX 2.1 Indicator bundles. Does **not** make network requests, scan hosts, or enrich indicators.
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
Python 3.10+:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
python -m pip install -e ".[dev]"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Examples
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
iocforge extract report.txt
|
|
37
|
+
iocforge extract report.txt --format csv --output indicators.csv
|
|
38
|
+
iocforge extract report.txt --format stix --output indicators.stix.json
|
|
39
|
+
cat report.txt | iocforge extract - --include-private
|
|
40
|
+
python -m iocforge extract report.txt
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Library API
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from iocforge import extract, export_json
|
|
47
|
+
print(export_json(extract("Observed hxxps://bad[.]example/path and 8.8.8.8")))
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Development
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
python -m pytest -q
|
|
54
|
+
ruff check .
|
|
55
|
+
python -m build
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Security and limitations
|
|
59
|
+
|
|
60
|
+
- Only process reports you have permission to handle. Reports may contain sensitive URLs or identifiers; review output before sharing.
|
|
61
|
+
- No automatic threat verdict: a matched indicator is **not** proof of malicious activity.
|
|
62
|
+
- Domain parsing is heuristic (not Public Suffix List-aware). IPv6, emails, and nested URL artifacts are not supported in v0.1.0.
|
|
63
|
+
- URLs containing embedded credentials are skipped. URL fragments are removed.
|
|
64
|
+
- By default, non-global IPv4 addresses are excluded; `--include-private` includes them.
|
|
65
|
+
- STIX output is a minimal Indicator bundle, not a complete threat-intelligence knowledge model. Consumers should validate it against their own STIX tooling.
|
|
66
|
+
- Extracted indicators are never queried or contacted by IOCForge.
|
|
67
|
+
|
|
68
|
+
## Publishing
|
|
69
|
+
|
|
70
|
+
Choose an available PyPI distribution name (currently configured as `iocforge-cli`), update project metadata, and configure PyPI Trusted Publishing for your repository. The release workflow publishes only on GitHub Releases.
|
|
71
|
+
|
|
72
|
+
Copyright © 2026 Noir Stack LLC. Licensed under the
|
|
73
|
+
[Proprietary Software License — Free Use, No Modification](LICENSE).
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# IOCForge
|
|
2
|
+
|
|
3
|
+
Offline threat indicator extraction and normalization for incident responders, threat-intelligence analysts, and OSINT workflows.
|
|
4
|
+
|
|
5
|
+
**Features:** extracts IPv4 addresses, domains, HTTP(S) URLs, MD5, SHA-1, and SHA-256 hashes; refangs common `hxxp`, `[.]`, `(.)`, and `[:]` notation; deduplicates; exports JSON, CSV, and basic STIX 2.1 Indicator bundles. Does **not** make network requests, scan hosts, or enrich indicators.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
Python 3.10+:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
python -m pip install -e ".[dev]"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Examples
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
iocforge extract report.txt
|
|
19
|
+
iocforge extract report.txt --format csv --output indicators.csv
|
|
20
|
+
iocforge extract report.txt --format stix --output indicators.stix.json
|
|
21
|
+
cat report.txt | iocforge extract - --include-private
|
|
22
|
+
python -m iocforge extract report.txt
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Library API
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from iocforge import extract, export_json
|
|
29
|
+
print(export_json(extract("Observed hxxps://bad[.]example/path and 8.8.8.8")))
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Development
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
python -m pytest -q
|
|
36
|
+
ruff check .
|
|
37
|
+
python -m build
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Security and limitations
|
|
41
|
+
|
|
42
|
+
- Only process reports you have permission to handle. Reports may contain sensitive URLs or identifiers; review output before sharing.
|
|
43
|
+
- No automatic threat verdict: a matched indicator is **not** proof of malicious activity.
|
|
44
|
+
- Domain parsing is heuristic (not Public Suffix List-aware). IPv6, emails, and nested URL artifacts are not supported in v0.1.0.
|
|
45
|
+
- URLs containing embedded credentials are skipped. URL fragments are removed.
|
|
46
|
+
- By default, non-global IPv4 addresses are excluded; `--include-private` includes them.
|
|
47
|
+
- STIX output is a minimal Indicator bundle, not a complete threat-intelligence knowledge model. Consumers should validate it against their own STIX tooling.
|
|
48
|
+
- Extracted indicators are never queried or contacted by IOCForge.
|
|
49
|
+
|
|
50
|
+
## Publishing
|
|
51
|
+
|
|
52
|
+
Choose an available PyPI distribution name (currently configured as `iocforge-cli`), update project metadata, and configure PyPI Trusted Publishing for your repository. The release workflow publishes only on GitHub Releases.
|
|
53
|
+
|
|
54
|
+
Copyright © 2026 Noir Stack LLC. Licensed under the
|
|
55
|
+
[Proprietary Software License — Free Use, No Modification](LICENSE).
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "iocforge-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Offline threat indicator extraction, normalization and reporting"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "Proprietary Software License — Free Use, No Modification" }
|
|
12
|
+
authors = [{name = "Noir Stack LLC"}]
|
|
13
|
+
keywords = ["ioc", "threat-intelligence", "dfir", "osint", "stix"]
|
|
14
|
+
classifiers = ["Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Topic :: Security"]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
dev = ["pytest>=8", "ruff>=0.6", "build>=1.2"]
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
iocforge = "iocforge.cli:main"
|
|
21
|
+
|
|
22
|
+
[tool.hatch.build.targets.wheel]
|
|
23
|
+
packages = ["src/iocforge"]
|
|
24
|
+
|
|
25
|
+
[tool.pytest.ini_options]
|
|
26
|
+
testpaths = ["tests"]
|
|
27
|
+
|
|
28
|
+
[tool.ruff]
|
|
29
|
+
line-length = 100
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Command-line entry point for IOCForge."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .core import export_csv, export_json, export_stix, extract
|
|
9
|
+
|
|
10
|
+
EXPORTERS = {"json": export_json, "csv": export_csv, "stix": export_stix}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main(argv: list[str] | None = None) -> int:
|
|
14
|
+
parser = argparse.ArgumentParser(prog="iocforge", description="Extract threat indicators from local text files, offline.")
|
|
15
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
16
|
+
cmd = sub.add_parser("extract", help="Extract indicators from a local report")
|
|
17
|
+
cmd.add_argument("source", help="UTF-8 text file path, or - for stdin")
|
|
18
|
+
cmd.add_argument("--format", choices=EXPORTERS, default="json")
|
|
19
|
+
cmd.add_argument("--output", "-o", help="Write output to a file (default stdout)")
|
|
20
|
+
cmd.add_argument("--include-private", action="store_true", help="Include non-global IPv4 addresses")
|
|
21
|
+
args = parser.parse_args(argv)
|
|
22
|
+
if args.command == "extract":
|
|
23
|
+
try:
|
|
24
|
+
data = sys.stdin.read() if args.source == "-" else Path(args.source).read_text(encoding="utf-8")
|
|
25
|
+
indicators = extract(data, include_private=args.include_private)
|
|
26
|
+
result = EXPORTERS[args.format](indicators)
|
|
27
|
+
if args.output:
|
|
28
|
+
Path(args.output).write_text(result, encoding="utf-8")
|
|
29
|
+
print(f"Wrote {len(indicators)} indicators to {args.output}", file=sys.stderr)
|
|
30
|
+
else:
|
|
31
|
+
sys.stdout.write(result)
|
|
32
|
+
return 0
|
|
33
|
+
except (OSError, UnicodeError) as exc:
|
|
34
|
+
print(f"iocforge: {exc}", file=sys.stderr)
|
|
35
|
+
return 1
|
|
36
|
+
return 2
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
if __name__ == "__main__":
|
|
40
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Offline extraction of common indicators of compromise from untrusted text."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import csv
|
|
5
|
+
import io
|
|
6
|
+
import ipaddress
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import uuid
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
12
|
+
|
|
13
|
+
# Defanged notation is common in public reports. Refang before matching.
|
|
14
|
+
URL_RE = re.compile(r"(?i)\bhttps?://[^\s<>\"'`]+")
|
|
15
|
+
IP_RE = re.compile(r"(?<![\w.])(?:\d{1,3}\.){3}\d{1,3}(?![\w.])")
|
|
16
|
+
DOMAIN_RE = re.compile(r"(?i)(?<![\w@.-])(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}(?![\w.-])")
|
|
17
|
+
HASH_RE = re.compile(r"(?i)(?<![a-f0-9])(?:[a-f0-9]{64}|[a-f0-9]{40}|[a-f0-9]{32})(?![a-f0-9])")
|
|
18
|
+
TRAILING = ".,;:!?)]}"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _refang(text: str) -> str:
|
|
22
|
+
text = re.sub(r"(?i)hxxps(?=://|\[:\]//)", "https", text)
|
|
23
|
+
text = re.sub(r"(?i)hxxp(?=://|\[:\]//)", "http", text)
|
|
24
|
+
text = re.sub(r"\[:\]", ":", text)
|
|
25
|
+
text = re.sub(r"\[\.\]|\(\.\)", ".", text)
|
|
26
|
+
return text
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _valid_domain(value: str) -> bool:
|
|
30
|
+
return len(value) <= 253 and all(len(x) <= 63 for x in value.split("."))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _normalize_url(value: str) -> str | None:
|
|
34
|
+
value = value.rstrip(TRAILING)
|
|
35
|
+
try:
|
|
36
|
+
p = urlsplit(value)
|
|
37
|
+
if p.scheme.lower() not in ("http", "https") or not p.hostname:
|
|
38
|
+
return None
|
|
39
|
+
host = p.hostname.lower()
|
|
40
|
+
if not _valid_domain(host) and not _is_ip(host):
|
|
41
|
+
return None
|
|
42
|
+
# URLs with credentials are intentionally rejected to avoid exporting secrets.
|
|
43
|
+
if p.username is not None or p.password is not None:
|
|
44
|
+
return None
|
|
45
|
+
port = p.port # May raise ValueError for invalid port.
|
|
46
|
+
netloc = host if port is None else f"{host}:{port}"
|
|
47
|
+
return urlunsplit((p.scheme.lower(), netloc, p.path or "", p.query, ""))
|
|
48
|
+
except ValueError:
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _is_ip(value: str) -> bool:
|
|
53
|
+
try:
|
|
54
|
+
ipaddress.ip_address(value)
|
|
55
|
+
return True
|
|
56
|
+
except ValueError:
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def extract(text: str, *, include_private: bool = False) -> list[dict[str, str]]:
|
|
61
|
+
"""Extract deduplicated indicators, without network requests or enrichment.
|
|
62
|
+
|
|
63
|
+
Default excludes non-global IPs; URLs and domains are extracted regardless of
|
|
64
|
+
DNS resolvability. This is heuristic extraction, not attribution or validation.
|
|
65
|
+
"""
|
|
66
|
+
text = _refang(text)
|
|
67
|
+
found: dict[tuple[str, str], dict[str, str]] = {}
|
|
68
|
+
|
|
69
|
+
def add(kind: str, value: str) -> None:
|
|
70
|
+
found[(kind, value)] = {"type": kind, "value": value}
|
|
71
|
+
|
|
72
|
+
url_spans: list[tuple[int, int]] = []
|
|
73
|
+
for match in URL_RE.finditer(text):
|
|
74
|
+
value = _normalize_url(match.group())
|
|
75
|
+
if value:
|
|
76
|
+
add("url", value)
|
|
77
|
+
url_spans.append(match.span())
|
|
78
|
+
|
|
79
|
+
def outside_urls(start: int, end: int) -> bool:
|
|
80
|
+
return not any(start < right and end > left for left, right in url_spans)
|
|
81
|
+
|
|
82
|
+
for match in IP_RE.finditer(text):
|
|
83
|
+
if not outside_urls(*match.span()):
|
|
84
|
+
continue
|
|
85
|
+
try:
|
|
86
|
+
address = ipaddress.ip_address(match.group())
|
|
87
|
+
if include_private or address.is_global:
|
|
88
|
+
add("ipv4", str(address))
|
|
89
|
+
except ValueError:
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
for match in DOMAIN_RE.finditer(text):
|
|
93
|
+
if not outside_urls(*match.span()):
|
|
94
|
+
continue
|
|
95
|
+
value = match.group().lower()
|
|
96
|
+
if _valid_domain(value) and not _is_ip(value):
|
|
97
|
+
add("domain", value)
|
|
98
|
+
|
|
99
|
+
for match in HASH_RE.finditer(text):
|
|
100
|
+
if outside_urls(*match.span()):
|
|
101
|
+
value = match.group().lower()
|
|
102
|
+
add({32: "md5", 40: "sha1", 64: "sha256"}[len(value)], value)
|
|
103
|
+
|
|
104
|
+
return [found[key] for key in sorted(found)]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def export_json(indicators: list[dict[str, str]]) -> str:
|
|
108
|
+
return json.dumps({"schema_version": "1.0", "count": len(indicators), "indicators": indicators}, indent=2) + "\n"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def export_csv(indicators: list[dict[str, str]]) -> str:
|
|
112
|
+
output = io.StringIO(newline="")
|
|
113
|
+
writer = csv.DictWriter(output, fieldnames=["type", "value"], lineterminator="\n")
|
|
114
|
+
writer.writeheader()
|
|
115
|
+
for item in indicators:
|
|
116
|
+
# Spreadsheet formula injection mitigation for CSV consumers.
|
|
117
|
+
value = item["value"]
|
|
118
|
+
if value.startswith(("=", "+", "-", "@", "\t", "\r")):
|
|
119
|
+
value = "'" + value
|
|
120
|
+
writer.writerow({"type": item["type"], "value": value})
|
|
121
|
+
return output.getvalue()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
_STIX_FIELDS = {"url": "url:value", "domain": "domain-name:value", "ipv4": "ipv4-addr:value", "md5": "file:hashes.MD5", "sha1": "file:hashes.'SHA-1'", "sha256": "file:hashes.'SHA-256'"}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def export_stix(indicators: list[dict[str, str]]) -> str:
|
|
128
|
+
"""Create a basic STIX 2.1 Indicator bundle; no confidence/attribution asserted."""
|
|
129
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
130
|
+
objects = []
|
|
131
|
+
for item in indicators:
|
|
132
|
+
kind, value = item["type"], item["value"]
|
|
133
|
+
if kind not in _STIX_FIELDS:
|
|
134
|
+
continue
|
|
135
|
+
safe_value = value.replace("\\", "\\\\").replace("'", "\\'")
|
|
136
|
+
pattern = f"[{_STIX_FIELDS[kind]} = '{safe_value}']"
|
|
137
|
+
stable_id = uuid.uuid5(uuid.NAMESPACE_URL, f"iocforge:{kind}:{value}")
|
|
138
|
+
objects.append({"type": "indicator", "spec_version": "2.1", "id": f"indicator--{stable_id}", "created": timestamp, "modified": timestamp, "name": f"IOCForge {kind} indicator", "pattern": pattern, "pattern_type": "stix", "valid_from": timestamp})
|
|
139
|
+
bundle = {"type": "bundle", "id": f"bundle--{uuid.uuid4()}", "objects": objects}
|
|
140
|
+
return json.dumps(bundle, indent=2) + "\n"
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
import io
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from iocforge.cli import main
|
|
6
|
+
from iocforge.core import export_csv, export_json, export_stix, extract
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_refanged_and_dedup():
|
|
10
|
+
data = "hxxps://Evil[.]example/path and hxxps://Evil[.]example/path and bad[.]example"
|
|
11
|
+
results = extract(data)
|
|
12
|
+
assert {tuple(x.values()) for x in results} == {("url", "https://evil.example/path"), ("domain", "bad.example")}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_ip_validation():
|
|
16
|
+
assert {x["value"] for x in extract("8.8.8.8 999.999.999.999 192.168.1.1") if x["type"] == "ipv4"} == {"8.8.8.8"}
|
|
17
|
+
assert any(x["value"] == "192.168.1.1" for x in extract("192.168.1.1", include_private=True))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_hashes():
|
|
21
|
+
values = ["a" * 32, "b" * 40, "c" * 64]
|
|
22
|
+
result = extract(" ".join(values))
|
|
23
|
+
assert {x["type"] for x in result} == {"md5", "sha1", "sha256"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_exporters():
|
|
27
|
+
items = extract("example.org 8.8.8.8")
|
|
28
|
+
assert json.loads(export_json(items))["count"] == 2
|
|
29
|
+
assert len(list(csv.DictReader(io.StringIO(export_csv(items))))) == 2
|
|
30
|
+
stix = json.loads(export_stix(items))
|
|
31
|
+
assert stix["type"] == "bundle" and len(stix["objects"]) == 2
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_cli(tmp_path):
|
|
35
|
+
source = tmp_path / "sample.txt"
|
|
36
|
+
target = tmp_path / "out.json"
|
|
37
|
+
source.write_text("malicious.example", encoding="utf-8")
|
|
38
|
+
assert main(["extract", str(source), "--output", str(target)]) == 0
|
|
39
|
+
assert json.loads(target.read_text())["count"] == 1
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_cli_missing_file(tmp_path):
|
|
43
|
+
assert main(["extract", str(tmp_path / "missing.txt")]) == 1
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_url_does_not_duplicate_host():
|
|
47
|
+
items = extract("https://example.com/path")
|
|
48
|
+
assert items == [{"type": "url", "value": "https://example.com/path"}]
|