truesignal-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.
- truesignal/__init__.py +44 -0
- truesignal/_util.py +28 -0
- truesignal/cli.py +201 -0
- truesignal/cli_helpers.py +314 -0
- truesignal/connectors/__init__.py +48 -0
- truesignal/connectors/cisa_kev.py +87 -0
- truesignal/connectors/cloudflare_radar.py +96 -0
- truesignal/connectors/gdelt.py +106 -0
- truesignal/connectors/reddit.py +135 -0
- truesignal/connectors/telegram.py +112 -0
- truesignal/http.py +96 -0
- truesignal/provenance/__init__.py +12 -0
- truesignal/provenance/cache.py +97 -0
- truesignal/provenance/stamp.py +98 -0
- truesignal/py.typed +0 -0
- truesignal/types.py +119 -0
- truesignal_cli-0.1.0.dist-info/METADATA +179 -0
- truesignal_cli-0.1.0.dist-info/RECORD +21 -0
- truesignal_cli-0.1.0.dist-info/WHEEL +4 -0
- truesignal_cli-0.1.0.dist-info/entry_points.txt +2 -0
- truesignal_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
truesignal/__init__.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Library entry point -- import truesignal's connectors and provenance layer programmatically.
|
|
3
|
+
|
|
4
|
+
from truesignal import ALL_CONNECTORS, get_connector
|
|
5
|
+
|
|
6
|
+
for connector in ALL_CONNECTORS:
|
|
7
|
+
for item in connector.fetch_items():
|
|
8
|
+
print(item.status, item.url)
|
|
9
|
+
|
|
10
|
+
This is the Python port of the truesignal-cli npm package
|
|
11
|
+
(https://www.npmjs.com/package/truesignal-cli). Both distributions ship the same five source
|
|
12
|
+
connectors (CISA-KEV, Cloudflare Radar, Reddit, Telegram, GDELT) and the same no-fabrication
|
|
13
|
+
provenance guarantee; see https://github.com/RudrenduPaul/truesignal for the canonical
|
|
14
|
+
documentation and the original TypeScript source.
|
|
15
|
+
"""
|
|
16
|
+
from .connectors import ALL_CONNECTORS, get_connector
|
|
17
|
+
from .provenance import (
|
|
18
|
+
fetch_with_fallback,
|
|
19
|
+
get_cache_dir,
|
|
20
|
+
read_cache,
|
|
21
|
+
stamp_fallback,
|
|
22
|
+
stamp_live,
|
|
23
|
+
write_cache,
|
|
24
|
+
)
|
|
25
|
+
from .types import Connector, ConnectorNotConfiguredError, FeedItem, ItemStatus, UnstampedItem
|
|
26
|
+
|
|
27
|
+
__version__ = "0.1.0"
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"Connector",
|
|
31
|
+
"FeedItem",
|
|
32
|
+
"ItemStatus",
|
|
33
|
+
"UnstampedItem",
|
|
34
|
+
"ConnectorNotConfiguredError",
|
|
35
|
+
"ALL_CONNECTORS",
|
|
36
|
+
"get_connector",
|
|
37
|
+
"fetch_with_fallback",
|
|
38
|
+
"stamp_live",
|
|
39
|
+
"stamp_fallback",
|
|
40
|
+
"read_cache",
|
|
41
|
+
"write_cache",
|
|
42
|
+
"get_cache_dir",
|
|
43
|
+
"__version__",
|
|
44
|
+
]
|
truesignal/_util.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Small internal helpers shared across connectors and the provenance layer. Not part of the
|
|
2
|
+
public API (hence the leading underscore) -- see truesignal/__init__.py for the public surface."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def format_iso8601(dt: datetime) -> str:
|
|
9
|
+
"""
|
|
10
|
+
Formats a datetime as a JavaScript-``Date.prototype.toISOString()``-equivalent string:
|
|
11
|
+
always UTC, always exactly 3 millisecond digits, always a trailing "Z". Naive datetimes are
|
|
12
|
+
assumed to already be UTC (every caller in this codebase constructs them that way).
|
|
13
|
+
"""
|
|
14
|
+
if dt.tzinfo is None:
|
|
15
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
16
|
+
dt = dt.astimezone(timezone.utc)
|
|
17
|
+
milliseconds = dt.microsecond // 1000
|
|
18
|
+
return dt.strftime("%Y-%m-%dT%H:%M:%S") + f".{milliseconds:03d}Z"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def parse_iso8601(value: str) -> datetime:
|
|
22
|
+
"""Parses a real upstream ISO-8601 timestamp (with or without a trailing 'Z') into an aware
|
|
23
|
+
UTC datetime. Raises ValueError on anything that isn't a real, parseable instant."""
|
|
24
|
+
normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
|
|
25
|
+
dt = datetime.fromisoformat(normalized)
|
|
26
|
+
if dt.tzinfo is None:
|
|
27
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
28
|
+
return dt.astimezone(timezone.utc)
|
truesignal/cli.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
truesignal CLI entry point. This module is deliberately thin -- it wires argparse's argument
|
|
4
|
+
parsing to the testable logic in cli_helpers.py and returns process exit codes. See ExitCode in
|
|
5
|
+
cli_helpers.py for what each code means.
|
|
6
|
+
|
|
7
|
+
Ported from src/truesignal/cli.ts (which uses `commander`); this port uses the stdlib `argparse`
|
|
8
|
+
to avoid a CLI-framework dependency. Console entry point: `truesignal <command> [options]`,
|
|
9
|
+
installed via the `truesignal` console-script defined in python/pyproject.toml.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
from typing import List, Optional
|
|
17
|
+
|
|
18
|
+
from .cli_helpers import (
|
|
19
|
+
ExitCode,
|
|
20
|
+
format_feed_item_human,
|
|
21
|
+
format_init_report,
|
|
22
|
+
get_connector_statuses,
|
|
23
|
+
run_feed,
|
|
24
|
+
run_verify,
|
|
25
|
+
)
|
|
26
|
+
from .connectors import ALL_CONNECTORS
|
|
27
|
+
|
|
28
|
+
_VERSION = "0.1.0"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
32
|
+
parser = argparse.ArgumentParser(
|
|
33
|
+
prog="truesignal",
|
|
34
|
+
description=(
|
|
35
|
+
"A provenance-first OSINT/security intelligence feed. Every item carries a real "
|
|
36
|
+
"source URL, a real timestamp, and an explicit live/fallback flag -- never a "
|
|
37
|
+
"fabricated or silently-replayed data point."
|
|
38
|
+
),
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument("--version", "-V", action="version", version=f"truesignal-cli {_VERSION}")
|
|
41
|
+
|
|
42
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
43
|
+
|
|
44
|
+
init_parser = subparsers.add_parser(
|
|
45
|
+
"init",
|
|
46
|
+
help=(
|
|
47
|
+
"Check which connectors are ready to use right now and which environment variables "
|
|
48
|
+
"are still needed for the rest. CISA-KEV and GDELT need no configuration -- "
|
|
49
|
+
"truesignal works with zero setup for those two sources."
|
|
50
|
+
),
|
|
51
|
+
)
|
|
52
|
+
init_parser.add_argument(
|
|
53
|
+
"--json", action="store_true", help="print machine-readable JSON instead of a human-readable report"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
feed_parser = subparsers.add_parser(
|
|
57
|
+
"feed",
|
|
58
|
+
help=(
|
|
59
|
+
"Pull the current feed from every configured connector, or one connector with "
|
|
60
|
+
"--source. Prints human-readable output by default; use --json for a stable, "
|
|
61
|
+
"agent-parseable schema."
|
|
62
|
+
),
|
|
63
|
+
)
|
|
64
|
+
feed_parser.add_argument("--source", default=None, help="only pull from this connector, e.g. cisa-kev, gdelt")
|
|
65
|
+
feed_parser.add_argument("--json", action="store_true", help="print machine-readable JSON instead of human-readable text")
|
|
66
|
+
|
|
67
|
+
verify_parser = subparsers.add_parser(
|
|
68
|
+
"verify",
|
|
69
|
+
help=(
|
|
70
|
+
"Re-fetch the source connector named in <item-id> and confirm whether that item "
|
|
71
|
+
"still resolves to real, live provenance, has fallen back to cached data, or can no "
|
|
72
|
+
"longer be found."
|
|
73
|
+
),
|
|
74
|
+
)
|
|
75
|
+
verify_parser.add_argument("item_id", help="a feed item id from `truesignal feed`, e.g. cisa-kev:CVE-2026-12345")
|
|
76
|
+
verify_parser.add_argument("--json", action="store_true", help="print machine-readable JSON instead of human-readable text")
|
|
77
|
+
|
|
78
|
+
return parser
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def run_cli(argv: List[str]) -> int:
|
|
82
|
+
"""
|
|
83
|
+
`argv` follows the sys.argv convention: argv[0] is the program name, the real arguments start
|
|
84
|
+
at argv[1]. Returns the process exit code.
|
|
85
|
+
"""
|
|
86
|
+
parser = build_parser()
|
|
87
|
+
args = parser.parse_args(argv[1:])
|
|
88
|
+
|
|
89
|
+
if args.command == "init":
|
|
90
|
+
return _run_init(json_output=args.json)
|
|
91
|
+
if args.command == "feed":
|
|
92
|
+
return _run_feed(source=args.source, json_output=args.json)
|
|
93
|
+
if args.command == "verify":
|
|
94
|
+
return _run_verify(item_id=args.item_id, json_output=args.json)
|
|
95
|
+
|
|
96
|
+
parser.print_help()
|
|
97
|
+
return ExitCode.SUCCESS
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _run_init(json_output: bool) -> int:
|
|
101
|
+
statuses = get_connector_statuses(ALL_CONNECTORS)
|
|
102
|
+
if json_output:
|
|
103
|
+
print(
|
|
104
|
+
json.dumps(
|
|
105
|
+
{
|
|
106
|
+
"connectors": [
|
|
107
|
+
{
|
|
108
|
+
"name": s.name,
|
|
109
|
+
"label": s.label,
|
|
110
|
+
"requires_config": s.requires_config,
|
|
111
|
+
"configured": s.configured,
|
|
112
|
+
"missing_env_vars": s.missing_env_vars,
|
|
113
|
+
}
|
|
114
|
+
for s in statuses
|
|
115
|
+
]
|
|
116
|
+
},
|
|
117
|
+
indent=2,
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
else:
|
|
121
|
+
print(format_init_report(statuses))
|
|
122
|
+
ready_count = sum(1 for s in statuses if not s.requires_config or s.configured)
|
|
123
|
+
return ExitCode.SUCCESS if ready_count > 0 else ExitCode.NO_CONNECTORS_CONFIGURED
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _run_feed(source: Optional[str], json_output: bool) -> int:
|
|
127
|
+
result = run_feed(ALL_CONNECTORS, source)
|
|
128
|
+
|
|
129
|
+
if result.unknown_source:
|
|
130
|
+
known = ", ".join(c.name for c in ALL_CONNECTORS)
|
|
131
|
+
sys.stderr.write(f'Unknown source "{result.unknown_source}". Known sources: {known}\n')
|
|
132
|
+
return ExitCode.GENERAL_ERROR
|
|
133
|
+
|
|
134
|
+
if not result.items and result.skipped and not result.failures:
|
|
135
|
+
if json_output:
|
|
136
|
+
print(json.dumps(result.to_dict(), indent=2))
|
|
137
|
+
else:
|
|
138
|
+
sys.stderr.write(
|
|
139
|
+
f"No connectors are configured to run. Missing: {', '.join(result.skipped)}. "
|
|
140
|
+
f'Run "truesignal init" to see what\'s needed.\n'
|
|
141
|
+
)
|
|
142
|
+
return ExitCode.NO_CONNECTORS_CONFIGURED
|
|
143
|
+
|
|
144
|
+
if json_output:
|
|
145
|
+
print(json.dumps(result.to_dict(), indent=2))
|
|
146
|
+
else:
|
|
147
|
+
if result.skipped:
|
|
148
|
+
print(f"Skipped (not configured): {', '.join(result.skipped)}")
|
|
149
|
+
if not result.items:
|
|
150
|
+
print("No items returned.")
|
|
151
|
+
for item in result.items:
|
|
152
|
+
print(format_feed_item_human(item))
|
|
153
|
+
for failure in result.failures:
|
|
154
|
+
sys.stderr.write(f"{failure.source}: {failure.error}\n")
|
|
155
|
+
|
|
156
|
+
if not result.items and result.failures:
|
|
157
|
+
return ExitCode.NETWORK_ERROR
|
|
158
|
+
return ExitCode.SUCCESS
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _run_verify(item_id: str, json_output: bool) -> int:
|
|
162
|
+
result = run_verify(ALL_CONNECTORS, item_id)
|
|
163
|
+
|
|
164
|
+
if json_output:
|
|
165
|
+
print(json.dumps(result.to_dict(), indent=2))
|
|
166
|
+
elif result.error_message:
|
|
167
|
+
sys.stderr.write(f"{result.error_message}\n")
|
|
168
|
+
elif result.found:
|
|
169
|
+
if result.status == "live":
|
|
170
|
+
provenance = "LIVE"
|
|
171
|
+
else:
|
|
172
|
+
provenance = f"FALLBACK ({result.fallback_age_seconds or 0}s old cached data)"
|
|
173
|
+
print(f"{item_id}: {provenance} -- {result.url} -- {result.timestamp}")
|
|
174
|
+
else:
|
|
175
|
+
print(f"{item_id}: not found in the current feed (it may have rolled off, or never existed).")
|
|
176
|
+
|
|
177
|
+
if result.error_kind in ("invalid-id", "unknown-source"):
|
|
178
|
+
return ExitCode.INVALID_ITEM_ID
|
|
179
|
+
if result.error_kind == "not-configured":
|
|
180
|
+
return ExitCode.NO_CONNECTORS_CONFIGURED
|
|
181
|
+
if result.error_kind == "network-error":
|
|
182
|
+
return ExitCode.NETWORK_ERROR
|
|
183
|
+
if not result.found:
|
|
184
|
+
return ExitCode.GENERAL_ERROR
|
|
185
|
+
return ExitCode.SUCCESS
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def main() -> None:
|
|
189
|
+
try:
|
|
190
|
+
code = run_cli(sys.argv)
|
|
191
|
+
except SystemExit:
|
|
192
|
+
raise
|
|
193
|
+
except Exception as error: # noqa: BLE001 -- top-level crash guard, mirrors src/cli.ts's catch-all
|
|
194
|
+
sys.stderr.write(f"{error}\n")
|
|
195
|
+
sys.exit(ExitCode.GENERAL_ERROR)
|
|
196
|
+
else:
|
|
197
|
+
sys.exit(code)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
if __name__ == "__main__":
|
|
201
|
+
main()
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Testable logic behind the CLI. cli.py wires this up to argparse and sys.exit(); every function
|
|
3
|
+
here is pure enough to unit test without spawning a real process.
|
|
4
|
+
|
|
5
|
+
Direct port of src/truesignal/cli-helpers.ts. Field/function names are snake_case here (Python
|
|
6
|
+
convention) where the TypeScript source uses camelCase.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import concurrent.futures
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from enum import IntEnum
|
|
15
|
+
from typing import Dict, List, Optional, Sequence, Tuple
|
|
16
|
+
|
|
17
|
+
from ._util import parse_iso8601
|
|
18
|
+
from .types import Connector, FeedItem, ItemStatus
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ExitCode(IntEnum):
|
|
22
|
+
"""Exit codes this CLI uses. Documented here and in the per-subcommand --help text."""
|
|
23
|
+
|
|
24
|
+
#: Command completed successfully.
|
|
25
|
+
SUCCESS = 0
|
|
26
|
+
#: Unexpected/uncategorized error.
|
|
27
|
+
GENERAL_ERROR = 1
|
|
28
|
+
#: The requested connector(s) exist but none are configured -- nothing could run.
|
|
29
|
+
NO_CONNECTORS_CONFIGURED = 2
|
|
30
|
+
#: A configured connector's upstream fetch failed and produced no fallback data either.
|
|
31
|
+
NETWORK_ERROR = 3
|
|
32
|
+
#: `verify <item-id>` was given an id that doesn't parse or names an unknown source.
|
|
33
|
+
INVALID_ITEM_ID = 4
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class ConnectorStatus:
|
|
38
|
+
name: str
|
|
39
|
+
label: str
|
|
40
|
+
requires_config: bool
|
|
41
|
+
configured: bool
|
|
42
|
+
missing_env_vars: List[str] = field(default_factory=list)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_connector_statuses(connectors: Sequence[Connector]) -> List[ConnectorStatus]:
|
|
46
|
+
"""Computes the configuration status of every connector, for `truesignal init`."""
|
|
47
|
+
statuses = []
|
|
48
|
+
for connector in connectors:
|
|
49
|
+
configured = connector.is_configured()
|
|
50
|
+
missing = list(connector.config_env_vars) if connector.requires_config and not configured else []
|
|
51
|
+
statuses.append(
|
|
52
|
+
ConnectorStatus(
|
|
53
|
+
name=connector.name,
|
|
54
|
+
label=connector.label,
|
|
55
|
+
requires_config=connector.requires_config,
|
|
56
|
+
configured=configured,
|
|
57
|
+
missing_env_vars=missing,
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
return statuses
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def format_init_report(statuses: Sequence[ConnectorStatus]) -> str:
|
|
64
|
+
"""Human-readable report for `truesignal init`."""
|
|
65
|
+
lines = ["truesignal connector status:", ""]
|
|
66
|
+
for status in statuses:
|
|
67
|
+
if not status.requires_config:
|
|
68
|
+
lines.append(f" [ready] {status.label} ({status.name}) -- no configuration needed")
|
|
69
|
+
elif status.configured:
|
|
70
|
+
lines.append(f" [ready] {status.label} ({status.name}) -- configured")
|
|
71
|
+
else:
|
|
72
|
+
lines.append(
|
|
73
|
+
f" [not configured] {status.label} ({status.name}) -- set {', '.join(status.missing_env_vars)}"
|
|
74
|
+
)
|
|
75
|
+
ready_count = sum(1 for s in statuses if not s.requires_config or s.configured)
|
|
76
|
+
lines.append("")
|
|
77
|
+
lines.append(f"{ready_count}/{len(statuses)} connectors ready.")
|
|
78
|
+
if ready_count == 0:
|
|
79
|
+
lines.append(
|
|
80
|
+
"No connectors are usable. This should not happen -- CISA-KEV and GDELT need no configuration."
|
|
81
|
+
)
|
|
82
|
+
else:
|
|
83
|
+
if ready_count < len(statuses):
|
|
84
|
+
lines.append("Set the missing environment variables above to enable the rest. See .env.example.")
|
|
85
|
+
lines.append('Next: run "truesignal feed" to see your feed now.')
|
|
86
|
+
return "\n".join(lines)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class ConnectorSelection:
|
|
91
|
+
#: Connectors to actually query.
|
|
92
|
+
to_query: List[Connector]
|
|
93
|
+
#: Connectors that exist but are not configured, skipped rather than failed on.
|
|
94
|
+
skipped: List[Connector]
|
|
95
|
+
#: Set if --source named a connector that does not exist.
|
|
96
|
+
unknown_source: Optional[str] = None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def select_connectors(
|
|
100
|
+
all_connectors: Sequence[Connector],
|
|
101
|
+
source_filter: Optional[str] = None,
|
|
102
|
+
) -> ConnectorSelection:
|
|
103
|
+
"""
|
|
104
|
+
Resolves which connectors `truesignal feed` should query, given an optional --source filter.
|
|
105
|
+
Unconfigured connectors are skipped (reported, never crashed on); an unknown --source name is
|
|
106
|
+
reported back as an error rather than silently ignored.
|
|
107
|
+
"""
|
|
108
|
+
candidates = list(all_connectors)
|
|
109
|
+
if source_filter:
|
|
110
|
+
match = next((c for c in all_connectors if c.name == source_filter), None)
|
|
111
|
+
if not match:
|
|
112
|
+
return ConnectorSelection(to_query=[], skipped=[], unknown_source=source_filter)
|
|
113
|
+
candidates = [match]
|
|
114
|
+
to_query = [c for c in candidates if not c.requires_config or c.is_configured()]
|
|
115
|
+
skipped = [c for c in candidates if c.requires_config and not c.is_configured()]
|
|
116
|
+
return ConnectorSelection(to_query=to_query, skipped=skipped)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _relative_age(timestamp: str, now: datetime) -> str:
|
|
120
|
+
ms = (now - parse_iso8601(timestamp)).total_seconds() * 1000
|
|
121
|
+
seconds = max(0, round(ms / 1000))
|
|
122
|
+
if seconds < 60:
|
|
123
|
+
return f"{seconds}s ago"
|
|
124
|
+
minutes = round(seconds / 60)
|
|
125
|
+
if minutes < 60:
|
|
126
|
+
return f"{minutes}m ago"
|
|
127
|
+
hours = round(minutes / 60)
|
|
128
|
+
if hours < 24:
|
|
129
|
+
return f"{hours}h ago"
|
|
130
|
+
days = round(hours / 24)
|
|
131
|
+
return f"{days}d ago"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _format_duration(seconds: int) -> str:
|
|
135
|
+
if seconds < 60:
|
|
136
|
+
return f"{seconds}s"
|
|
137
|
+
minutes = round(seconds / 60)
|
|
138
|
+
if minutes < 60:
|
|
139
|
+
return f"{minutes}m"
|
|
140
|
+
hours = round(minutes / 60)
|
|
141
|
+
if hours < 24:
|
|
142
|
+
return f"{hours}h"
|
|
143
|
+
days = round(hours / 24)
|
|
144
|
+
return f"{days}d"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
_CONTROL_CHAR_PATTERN = re.compile("[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _sanitize_for_terminal(text: str) -> str:
|
|
151
|
+
"""
|
|
152
|
+
Strips control characters (C0/C1, DEL) from untrusted upstream text before it reaches a
|
|
153
|
+
terminal. Reddit and Telegram content is attacker-controlled -- a crafted post title or
|
|
154
|
+
message could otherwise carry ANSI/OSC escape sequences (cursor manipulation, terminal-title
|
|
155
|
+
spoofing, an OSC-8 hyperlink escape that visually disguises the printed URL) straight into
|
|
156
|
+
the user's terminal via `print`.
|
|
157
|
+
"""
|
|
158
|
+
return _CONTROL_CHAR_PATTERN.sub("", text)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def format_feed_item_human(item: FeedItem, now: Optional[datetime] = None) -> str:
|
|
162
|
+
"""Formats one feed item as a single human-readable line."""
|
|
163
|
+
now = now or datetime.now(timezone.utc)
|
|
164
|
+
if item.status == "live":
|
|
165
|
+
tag = "[live]"
|
|
166
|
+
else:
|
|
167
|
+
tag = f"[fallback, {_format_duration(item.fallback_age_seconds or 0)} old]"
|
|
168
|
+
title = _sanitize_for_terminal(item.title)
|
|
169
|
+
url = _sanitize_for_terminal(item.url)
|
|
170
|
+
return f"{tag} {item.source}: {title} -- {url} -- {_relative_age(item.timestamp, now)}"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def parse_item_id(item_id: str) -> Optional[Tuple[str, str]]:
|
|
174
|
+
"""Splits a feed item id (`{source}:{native_id}`) into its source and native-id parts."""
|
|
175
|
+
separator_index = item_id.find(":")
|
|
176
|
+
if separator_index <= 0 or separator_index == len(item_id) - 1:
|
|
177
|
+
return None
|
|
178
|
+
return item_id[:separator_index], item_id[separator_index + 1 :]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@dataclass(frozen=True)
|
|
182
|
+
class ConnectorFailure:
|
|
183
|
+
source: str
|
|
184
|
+
error: str
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass(frozen=True)
|
|
188
|
+
class FeedRunResult:
|
|
189
|
+
items: List[FeedItem]
|
|
190
|
+
skipped: List[str]
|
|
191
|
+
failures: List[ConnectorFailure]
|
|
192
|
+
unknown_source: Optional[str] = None
|
|
193
|
+
|
|
194
|
+
def to_dict(self) -> Dict:
|
|
195
|
+
data: Dict = {
|
|
196
|
+
"items": [item.to_dict() for item in self.items],
|
|
197
|
+
"skipped": self.skipped,
|
|
198
|
+
"failures": [{"source": f.source, "error": f.error} for f in self.failures],
|
|
199
|
+
}
|
|
200
|
+
if self.unknown_source is not None:
|
|
201
|
+
data["unknown_source"] = self.unknown_source
|
|
202
|
+
return data
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def run_feed(connectors: Sequence[Connector], source_filter: Optional[str] = None) -> FeedRunResult:
|
|
206
|
+
"""
|
|
207
|
+
Runs `truesignal feed` end to end against real connectors: resolves which to query, fetches
|
|
208
|
+
them concurrently, and never lets one connector's failure take down the others.
|
|
209
|
+
"""
|
|
210
|
+
selection = select_connectors(connectors, source_filter)
|
|
211
|
+
if selection.unknown_source:
|
|
212
|
+
return FeedRunResult(items=[], skipped=[], failures=[], unknown_source=selection.unknown_source)
|
|
213
|
+
|
|
214
|
+
items: List[FeedItem] = []
|
|
215
|
+
failures: List[ConnectorFailure] = []
|
|
216
|
+
|
|
217
|
+
if selection.to_query:
|
|
218
|
+
# Preserve connector order in the aggregated output, same as Promise.allSettled's
|
|
219
|
+
# index-ordered results in the TypeScript source.
|
|
220
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=len(selection.to_query)) as executor:
|
|
221
|
+
futures = [executor.submit(connector.fetch_items) for connector in selection.to_query]
|
|
222
|
+
for connector, future in zip(selection.to_query, futures):
|
|
223
|
+
try:
|
|
224
|
+
items.extend(future.result())
|
|
225
|
+
except Exception as error: # noqa: BLE001 -- one connector's failure must not sink the others
|
|
226
|
+
failures.append(ConnectorFailure(source=connector.name, error=str(error)))
|
|
227
|
+
|
|
228
|
+
return FeedRunResult(items=items, skipped=[c.name for c in selection.skipped], failures=failures)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
VerifyErrorKind = str # "invalid-id" | "unknown-source" | "not-configured" | "network-error"
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@dataclass(frozen=True)
|
|
235
|
+
class VerifyResult:
|
|
236
|
+
item_id: str
|
|
237
|
+
found: bool
|
|
238
|
+
status: Optional[ItemStatus] = None
|
|
239
|
+
url: Optional[str] = None
|
|
240
|
+
timestamp: Optional[str] = None
|
|
241
|
+
fallback_age_seconds: Optional[int] = None
|
|
242
|
+
error_kind: Optional[VerifyErrorKind] = None
|
|
243
|
+
error_message: Optional[str] = None
|
|
244
|
+
|
|
245
|
+
def to_dict(self) -> Dict:
|
|
246
|
+
data: Dict = {"item_id": self.item_id, "found": self.found}
|
|
247
|
+
for key, value in (
|
|
248
|
+
("status", self.status),
|
|
249
|
+
("url", self.url),
|
|
250
|
+
("timestamp", self.timestamp),
|
|
251
|
+
("fallback_age_seconds", self.fallback_age_seconds),
|
|
252
|
+
("error_kind", self.error_kind),
|
|
253
|
+
("error_message", self.error_message),
|
|
254
|
+
):
|
|
255
|
+
if value is not None:
|
|
256
|
+
data[key] = value
|
|
257
|
+
return data
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def run_verify(connectors: Sequence[Connector], item_id: str) -> VerifyResult:
|
|
261
|
+
"""Re-fetches the named connector and confirms whether `item_id` still resolves to real data."""
|
|
262
|
+
parsed = parse_item_id(item_id)
|
|
263
|
+
if not parsed:
|
|
264
|
+
return VerifyResult(
|
|
265
|
+
item_id=item_id,
|
|
266
|
+
found=False,
|
|
267
|
+
error_kind="invalid-id",
|
|
268
|
+
error_message=(
|
|
269
|
+
f'Could not parse item id "{item_id}". Expected format: <source>:<native-id>, '
|
|
270
|
+
f"e.g. cisa-kev:CVE-2026-12345"
|
|
271
|
+
),
|
|
272
|
+
)
|
|
273
|
+
source, _native_id = parsed
|
|
274
|
+
|
|
275
|
+
connector = next((c for c in connectors if c.name == source), None)
|
|
276
|
+
if not connector:
|
|
277
|
+
known = ", ".join(c.name for c in connectors)
|
|
278
|
+
return VerifyResult(
|
|
279
|
+
item_id=item_id,
|
|
280
|
+
found=False,
|
|
281
|
+
error_kind="unknown-source",
|
|
282
|
+
error_message=f'Unknown source "{source}" in item id "{item_id}". Known sources: {known}',
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
if connector.requires_config and not connector.is_configured():
|
|
286
|
+
return VerifyResult(
|
|
287
|
+
item_id=item_id,
|
|
288
|
+
found=False,
|
|
289
|
+
error_kind="not-configured",
|
|
290
|
+
error_message=f'Connector "{connector.name}" is not configured. Run "truesignal init" to see what is needed.',
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
try:
|
|
294
|
+
items = connector.fetch_items()
|
|
295
|
+
except Exception as error: # noqa: BLE001 -- mirrors src/cli-helpers.ts's catch-all around fetchItems()
|
|
296
|
+
return VerifyResult(
|
|
297
|
+
item_id=item_id,
|
|
298
|
+
found=False,
|
|
299
|
+
error_kind="network-error",
|
|
300
|
+
error_message=f"Failed to verify: {error}",
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
found_item = next((item for item in items if item.id == item_id), None)
|
|
304
|
+
if not found_item:
|
|
305
|
+
return VerifyResult(item_id=item_id, found=False)
|
|
306
|
+
|
|
307
|
+
return VerifyResult(
|
|
308
|
+
item_id=item_id,
|
|
309
|
+
found=True,
|
|
310
|
+
status=found_item.status,
|
|
311
|
+
url=found_item.url,
|
|
312
|
+
timestamp=found_item.timestamp,
|
|
313
|
+
fallback_age_seconds=found_item.fallback_age_seconds,
|
|
314
|
+
)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Every connector this build ships, in a fixed order. Adding a new source is: implement Connector
|
|
3
|
+
(truesignal/types.py) in a new module in this package, then add it to ALL_CONNECTORS below --
|
|
4
|
+
nothing else in the CLI or provenance layer needs to change. See CONTRIBUTING.md.
|
|
5
|
+
|
|
6
|
+
Direct port of src/truesignal/connectors/index.ts.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import List, Optional, Sequence
|
|
11
|
+
|
|
12
|
+
from ..types import Connector
|
|
13
|
+
from .cisa_kev import CisaKevConnector, cisa_kev_connector
|
|
14
|
+
from .cloudflare_radar import CloudflareRadarConnector, cloudflare_radar_connector
|
|
15
|
+
from .gdelt import GdeltConnector, gdelt_connector
|
|
16
|
+
from .reddit import RedditConnector, reddit_connector
|
|
17
|
+
from .telegram import TelegramConnector, telegram_connector
|
|
18
|
+
|
|
19
|
+
ALL_CONNECTORS: Sequence[Connector] = (
|
|
20
|
+
cisa_kev_connector,
|
|
21
|
+
cloudflare_radar_connector,
|
|
22
|
+
reddit_connector,
|
|
23
|
+
telegram_connector,
|
|
24
|
+
gdelt_connector,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_connector(name: str) -> Optional[Connector]:
|
|
29
|
+
for connector in ALL_CONNECTORS:
|
|
30
|
+
if connector.name == name:
|
|
31
|
+
return connector
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"ALL_CONNECTORS",
|
|
37
|
+
"get_connector",
|
|
38
|
+
"CisaKevConnector",
|
|
39
|
+
"cisa_kev_connector",
|
|
40
|
+
"CloudflareRadarConnector",
|
|
41
|
+
"cloudflare_radar_connector",
|
|
42
|
+
"RedditConnector",
|
|
43
|
+
"reddit_connector",
|
|
44
|
+
"TelegramConnector",
|
|
45
|
+
"telegram_connector",
|
|
46
|
+
"GdeltConnector",
|
|
47
|
+
"gdelt_connector",
|
|
48
|
+
]
|