iocforge-cli 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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"]
iocforge/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
iocforge/cli.py ADDED
@@ -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())
iocforge/core.py ADDED
@@ -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,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,9 @@
1
+ iocforge/__init__.py,sha256=JNFDQ4pma2W8nVnUdm4Cx46Z8VP690LAr58l5Vp3m9Q,217
2
+ iocforge/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
3
+ iocforge/cli.py,sha256=4zpXnxubz9T4Rz2uAlmXS7e8WTkUlD5ZtYuZ2RArpVE,1724
4
+ iocforge/core.py,sha256=7uc4twWBI8F7MAFIOV29fnZCugINPoUnur5GuPbUeu4,5506
5
+ iocforge_cli-0.1.0.dist-info/METADATA,sha256=ZvtVyd5q07SW2_d2JCZt8lgFBLBQ0Gazqd8nxtXCXOo,2808
6
+ iocforge_cli-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
7
+ iocforge_cli-0.1.0.dist-info/entry_points.txt,sha256=5Y5yel_1thRQ3jjGSdK8ctNOH7LzMV0T1vjcqKiQ8CE,47
8
+ iocforge_cli-0.1.0.dist-info/licenses/LICENSE,sha256=WMqIJXD3rBuirXChYblF7ipgFsKgYZR2DdUpEV-agjk,1527
9
+ iocforge_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ iocforge = iocforge.cli:main
@@ -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.