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.
@@ -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,7 @@
1
+ .venv/
2
+ .pytest_cache/
3
+ .ruff_cache/
4
+ __pycache__/
5
+ *.py[cod]
6
+ dist/
7
+ *.egg-info/
@@ -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,5 @@
1
+ """IOCForge: offline indicator extraction and normalization."""
2
+ from .core import export_csv, export_json, export_stix, extract
3
+
4
+ __version__ = "0.1.0"
5
+ __all__ = ["export_csv", "export_json", "export_stix", "extract"]
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -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"}]