nostos 1.0.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.
- core/__init__.py +4 -0
- core/auth.py +165 -0
- core/cli.py +226 -0
- core/commands/__init__.py +6 -0
- core/commands/_common.py +51 -0
- core/commands/add.py +118 -0
- core/commands/attack.py +83 -0
- core/commands/dashboard.py +86 -0
- core/commands/digest.py +77 -0
- core/commands/doctor.py +91 -0
- core/commands/export_cmd.py +89 -0
- core/commands/import_cmd.py +151 -0
- core/commands/list_cmd.py +151 -0
- core/commands/note.py +33 -0
- core/commands/pull.py +267 -0
- core/commands/refresh.py +217 -0
- core/commands/rm.py +116 -0
- core/commands/show.py +91 -0
- core/commands/tag.py +61 -0
- core/commands/triage.py +101 -0
- core/commands/update.py +168 -0
- core/commands/vault.py +230 -0
- core/config.py +105 -0
- core/dashboard.py +223 -0
- core/digest.py +177 -0
- core/discovery.py +73 -0
- core/doctor.py +198 -0
- core/index.py +645 -0
- core/logging_config.py +63 -0
- core/models.py +28 -0
- core/output.py +155 -0
- core/paths.py +78 -0
- core/portable.py +247 -0
- core/py.typed +0 -0
- core/taxonomy.py +137 -0
- core/updater.py +264 -0
- core/updater_self.py +273 -0
- core/upstream.py +421 -0
- core/vault.py +604 -0
- core/watchlist.py +280 -0
- nostos-1.0.0.dist-info/METADATA +1175 -0
- nostos-1.0.0.dist-info/RECORD +47 -0
- nostos-1.0.0.dist-info/WHEEL +5 -0
- nostos-1.0.0.dist-info/entry_points.txt +2 -0
- nostos-1.0.0.dist-info/licenses/LICENSE +21 -0
- nostos-1.0.0.dist-info/top_level.txt +2 -0
- nostos.py +21 -0
core/__init__.py
ADDED
core/auth.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Auth config for nostos upstream probes.
|
|
2
|
+
|
|
3
|
+
Reads $XDG_CONFIG_HOME/nostos/auth.toml and exposes a per-host view:
|
|
4
|
+
|
|
5
|
+
[hosts."github.com"]
|
|
6
|
+
token_env = "GITHUB_TOKEN" # preferred
|
|
7
|
+
# token = "ghp_..." # inline (discouraged; use env var)
|
|
8
|
+
# provider = "github" # optional override; default derived from host
|
|
9
|
+
|
|
10
|
+
[hosts."gitlab.internal.corp"]
|
|
11
|
+
provider = "gitlab" # required for non-standard hosts
|
|
12
|
+
token_env = "CORP_GITLAB_TOKEN"
|
|
13
|
+
|
|
14
|
+
[defaults]
|
|
15
|
+
allow_unknown = false # fail-closed for hosts not listed above
|
|
16
|
+
|
|
17
|
+
Security
|
|
18
|
+
- The file must be owned by the current user and not world-readable
|
|
19
|
+
on Unix. Failure of either check causes the loader to refuse to
|
|
20
|
+
read it and return an empty / fail-closed config with a warning.
|
|
21
|
+
- A missing file is NOT an error; upstream probes will simply fail
|
|
22
|
+
closed for every host.
|
|
23
|
+
- Inline tokens are supported but discouraged; token_env is always
|
|
24
|
+
preferred and is the only form that is redacted from audit output.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import logging
|
|
30
|
+
import os
|
|
31
|
+
import stat
|
|
32
|
+
import sys
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from .paths import auth_config_path
|
|
36
|
+
|
|
37
|
+
# Python 3.11+ ships tomllib in the stdlib. On 3.10 we fall back to
|
|
38
|
+
# tomli if it happens to be installed; otherwise the TOML loader is
|
|
39
|
+
# disabled entirely and load_auth() returns a fail-closed config with
|
|
40
|
+
# a clear warning. This keeps nostos importable with zero runtime
|
|
41
|
+
# dependencies on every supported Python, and keeps the opsec posture
|
|
42
|
+
# correct: no TOML parser means no auth.toml is read, which means
|
|
43
|
+
# every upstream probe is skipped fail-closed.
|
|
44
|
+
_toml: Any = None
|
|
45
|
+
try:
|
|
46
|
+
import tomllib as _toml # type: ignore[import-not-found,no-redef,unused-ignore]
|
|
47
|
+
except ModuleNotFoundError: # pragma: no cover - Python 3.10
|
|
48
|
+
try:
|
|
49
|
+
import tomli as _toml # type: ignore[import-not-found,no-redef]
|
|
50
|
+
except ModuleNotFoundError:
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_DEFAULT_PROVIDERS: dict[str, str] = {
|
|
55
|
+
"github.com": "github",
|
|
56
|
+
"gitlab.com": "gitlab",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class AuthConfig:
|
|
61
|
+
"""In-memory view of the auth.toml file.
|
|
62
|
+
|
|
63
|
+
Hosts not present in the config produce (None, False) from
|
|
64
|
+
resolve_host_token() unless defaults.allow_unknown is True.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
hosts: dict[str, dict[str, Any]] | None = None,
|
|
70
|
+
allow_unknown: bool = False,
|
|
71
|
+
) -> None:
|
|
72
|
+
self.hosts = hosts or {}
|
|
73
|
+
self.allow_unknown = allow_unknown
|
|
74
|
+
|
|
75
|
+
def provider_for(self, host: str) -> str | None:
|
|
76
|
+
"""Return the provider name for a host, or None if unknown."""
|
|
77
|
+
cfg = self.hosts.get(host)
|
|
78
|
+
if cfg and cfg.get("provider"):
|
|
79
|
+
return str(cfg["provider"]).lower()
|
|
80
|
+
return _DEFAULT_PROVIDERS.get(host)
|
|
81
|
+
|
|
82
|
+
def resolve_token(self, host: str) -> str | None:
|
|
83
|
+
"""Return the token for a host, or None.
|
|
84
|
+
|
|
85
|
+
token_env takes precedence over inline token. Unknown hosts
|
|
86
|
+
always return None.
|
|
87
|
+
"""
|
|
88
|
+
cfg = self.hosts.get(host)
|
|
89
|
+
if not cfg:
|
|
90
|
+
return None
|
|
91
|
+
env_name = cfg.get("token_env")
|
|
92
|
+
if env_name:
|
|
93
|
+
return os.environ.get(str(env_name)) or None
|
|
94
|
+
inline = cfg.get("token")
|
|
95
|
+
return str(inline) if inline else None
|
|
96
|
+
|
|
97
|
+
def is_allowed(self, host: str) -> bool:
|
|
98
|
+
"""Should a probe against this host be permitted?
|
|
99
|
+
|
|
100
|
+
True when the host is explicitly configured OR when
|
|
101
|
+
defaults.allow_unknown is True.
|
|
102
|
+
"""
|
|
103
|
+
if host in self.hosts:
|
|
104
|
+
return True
|
|
105
|
+
return self.allow_unknown
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _is_auth_file_safe(path: str) -> bool:
|
|
109
|
+
"""Reject if not owned by current user or world-readable/writable (Unix)."""
|
|
110
|
+
try:
|
|
111
|
+
st = os.stat(path)
|
|
112
|
+
except OSError:
|
|
113
|
+
return False
|
|
114
|
+
if sys.platform == "win32":
|
|
115
|
+
return True
|
|
116
|
+
if st.st_uid != os.getuid():
|
|
117
|
+
logging.warning(
|
|
118
|
+
f"Ignoring {path}: owned by uid {st.st_uid}, not current user"
|
|
119
|
+
)
|
|
120
|
+
return False
|
|
121
|
+
if st.st_mode & (stat.S_IWOTH | stat.S_IROTH | stat.S_IWGRP | stat.S_IRGRP):
|
|
122
|
+
logging.warning(
|
|
123
|
+
f"Ignoring {path}: permissions too loose "
|
|
124
|
+
f"(fix with: chmod 600 {path})"
|
|
125
|
+
)
|
|
126
|
+
return False
|
|
127
|
+
return True
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def load_auth(path: str | None = None) -> AuthConfig:
|
|
131
|
+
"""Load the auth config. Returns an empty fail-closed config if
|
|
132
|
+
the file is missing, unsafe, or malformed."""
|
|
133
|
+
cfg_path = path or auth_config_path()
|
|
134
|
+
if not os.path.isfile(cfg_path):
|
|
135
|
+
return AuthConfig()
|
|
136
|
+
if not _is_auth_file_safe(cfg_path):
|
|
137
|
+
return AuthConfig()
|
|
138
|
+
if _toml is None:
|
|
139
|
+
logging.warning(
|
|
140
|
+
f"Ignoring {cfg_path}: no TOML parser available on this Python. "
|
|
141
|
+
f"Upgrade to Python 3.11+ (preferred) or install 'tomli' "
|
|
142
|
+
f"for 3.10. Upstream probes will fail closed until then."
|
|
143
|
+
)
|
|
144
|
+
return AuthConfig()
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
with open(cfg_path, "rb") as f:
|
|
148
|
+
data = _toml.load(f)
|
|
149
|
+
except (OSError, _toml.TOMLDecodeError) as e:
|
|
150
|
+
logging.warning(f"Ignoring {cfg_path}: parse error ({e})")
|
|
151
|
+
return AuthConfig()
|
|
152
|
+
|
|
153
|
+
hosts_raw = data.get("hosts", {})
|
|
154
|
+
hosts: dict[str, dict[str, Any]] = {}
|
|
155
|
+
if isinstance(hosts_raw, dict):
|
|
156
|
+
for name, cfg in hosts_raw.items():
|
|
157
|
+
if isinstance(cfg, dict):
|
|
158
|
+
hosts[str(name)] = {k: v for k, v in cfg.items()}
|
|
159
|
+
|
|
160
|
+
defaults = data.get("defaults", {})
|
|
161
|
+
allow_unknown = False
|
|
162
|
+
if isinstance(defaults, dict):
|
|
163
|
+
allow_unknown = bool(defaults.get("allow_unknown", False))
|
|
164
|
+
|
|
165
|
+
return AuthConfig(hosts=hosts, allow_unknown=allow_unknown)
|
core/cli.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Argparse / subcommand dispatch for nostos.
|
|
2
|
+
|
|
3
|
+
The top-level command is a verb-first subparser tree: `nostos pull`,
|
|
4
|
+
`nostos add`, `nostos list`, etc. Invocations without a verb are
|
|
5
|
+
treated as an implicit `pull` for backward compatibility. The legacy
|
|
6
|
+
top-level flags (--add, --remove, --list) are rewritten to their
|
|
7
|
+
subcommand equivalents and emit a deprecation notice.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
# PYTHON_ARGCOMPLETE_OK
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
from importlib.metadata import PackageNotFoundError
|
|
18
|
+
from importlib.metadata import version as _pkg_version
|
|
19
|
+
|
|
20
|
+
from .commands import add as _cmd_add
|
|
21
|
+
from .commands import attack as _cmd_attack
|
|
22
|
+
from .commands import dashboard as _cmd_dashboard
|
|
23
|
+
from .commands import digest as _cmd_digest
|
|
24
|
+
from .commands import doctor as _cmd_doctor
|
|
25
|
+
from .commands import export_cmd as _cmd_export
|
|
26
|
+
from .commands import import_cmd as _cmd_import
|
|
27
|
+
from .commands import list_cmd as _cmd_list
|
|
28
|
+
from .commands import note as _cmd_note
|
|
29
|
+
from .commands import pull as _cmd_pull
|
|
30
|
+
from .commands import refresh as _cmd_refresh
|
|
31
|
+
from .commands import rm as _cmd_rm
|
|
32
|
+
from .commands import show as _cmd_show
|
|
33
|
+
from .commands import tag as _cmd_tag
|
|
34
|
+
from .commands import triage as _cmd_triage
|
|
35
|
+
from .commands import update as _cmd_update
|
|
36
|
+
from .commands import vault as _cmd_vault
|
|
37
|
+
|
|
38
|
+
_KNOWN_VERBS: frozenset[str] = frozenset(
|
|
39
|
+
{
|
|
40
|
+
"pull", "add", "list", "show", "tag", "note", "triage",
|
|
41
|
+
"rm", "refresh", "digest", "vault", "export", "import",
|
|
42
|
+
"update", "doctor", "attack", "dashboard",
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_version() -> str:
|
|
48
|
+
"""Return the nostos version.
|
|
49
|
+
|
|
50
|
+
Reads the VERSION file at the repo root first so source-tree edits
|
|
51
|
+
are reflected immediately. Falls back to installed package metadata
|
|
52
|
+
for pip-installed runs.
|
|
53
|
+
"""
|
|
54
|
+
version_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), "VERSION")
|
|
55
|
+
try:
|
|
56
|
+
with open(version_file) as f:
|
|
57
|
+
value = f.read().strip()
|
|
58
|
+
if value:
|
|
59
|
+
return value
|
|
60
|
+
except OSError:
|
|
61
|
+
pass
|
|
62
|
+
try:
|
|
63
|
+
return _pkg_version("nostos")
|
|
64
|
+
except PackageNotFoundError:
|
|
65
|
+
return "unknown"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
69
|
+
parser = argparse.ArgumentParser(
|
|
70
|
+
prog="nostos",
|
|
71
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
72
|
+
description=(
|
|
73
|
+
"nostos - manage a fleet of git repositories.\n"
|
|
74
|
+
"\n"
|
|
75
|
+
"Pull updates in parallel, maintain a curated index with tags, "
|
|
76
|
+
"status, and notes, probe upstream metadata (stars, archived, "
|
|
77
|
+
"last push), generate weekly digests and HTML dashboards, "
|
|
78
|
+
"bridge to an Obsidian vault, and export portable bundles "
|
|
79
|
+
"that move cleanly between machines."
|
|
80
|
+
),
|
|
81
|
+
epilog=(
|
|
82
|
+
"Examples:\n"
|
|
83
|
+
" nostos pull ~/code Pull every repo under ~/code\n"
|
|
84
|
+
" nostos pull --from-index --tags Pull indexed repos + all their git tags\n"
|
|
85
|
+
" nostos add URL --tag python Clone a remote repo and register it\n"
|
|
86
|
+
" nostos list --tag security Show indexed repos carrying a tag\n"
|
|
87
|
+
" nostos refresh Fetch upstream metadata for the index\n"
|
|
88
|
+
" nostos export --out fleet.json Export the full index for portability\n"
|
|
89
|
+
"\n"
|
|
90
|
+
"Run `nostos <command> --help` for flags on a specific command.\n"
|
|
91
|
+
"Docs: https://github.com/prodrom3/nostos"
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
parser.add_argument(
|
|
95
|
+
"-v",
|
|
96
|
+
"--version",
|
|
97
|
+
action="version",
|
|
98
|
+
version=f"%(prog)s {get_version()}",
|
|
99
|
+
)
|
|
100
|
+
subparsers = parser.add_subparsers(
|
|
101
|
+
title="commands",
|
|
102
|
+
dest="command",
|
|
103
|
+
metavar="COMMAND",
|
|
104
|
+
)
|
|
105
|
+
# Group the verbs in a logical order so `--help` reads top to bottom.
|
|
106
|
+
# Core pull / index intake:
|
|
107
|
+
_cmd_pull.add_parser(subparsers)
|
|
108
|
+
_cmd_add.add_parser(subparsers)
|
|
109
|
+
# Index inspection & editing:
|
|
110
|
+
_cmd_list.add_parser(subparsers)
|
|
111
|
+
_cmd_show.add_parser(subparsers)
|
|
112
|
+
_cmd_tag.add_parser(subparsers)
|
|
113
|
+
_cmd_note.add_parser(subparsers)
|
|
114
|
+
_cmd_triage.add_parser(subparsers)
|
|
115
|
+
_cmd_rm.add_parser(subparsers)
|
|
116
|
+
# Upstream intelligence & reporting:
|
|
117
|
+
_cmd_refresh.add_parser(subparsers)
|
|
118
|
+
_cmd_digest.add_parser(subparsers)
|
|
119
|
+
_cmd_dashboard.add_parser(subparsers)
|
|
120
|
+
_cmd_attack.add_parser(subparsers)
|
|
121
|
+
# External bridges & portability:
|
|
122
|
+
_cmd_vault.add_parser(subparsers)
|
|
123
|
+
_cmd_export.add_parser(subparsers)
|
|
124
|
+
_cmd_import.add_parser(subparsers)
|
|
125
|
+
# Operational / housekeeping:
|
|
126
|
+
_cmd_update.add_parser(subparsers)
|
|
127
|
+
_cmd_doctor.add_parser(subparsers)
|
|
128
|
+
return parser
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _rewrite_legacy_argv(argv: list[str]) -> list[str]:
|
|
132
|
+
"""Translate legacy top-level flags to subcommand form.
|
|
133
|
+
|
|
134
|
+
Keeps `nostos --add X`, `nostos --remove X`, `nostos --list`,
|
|
135
|
+
and `nostos --watchlist` working for one release. Users get a
|
|
136
|
+
one-line deprecation notice to stderr when a legacy flag fires.
|
|
137
|
+
"""
|
|
138
|
+
if not argv:
|
|
139
|
+
return argv
|
|
140
|
+
|
|
141
|
+
# --list with no other verb -> `list`
|
|
142
|
+
if argv and argv[0] == "--list":
|
|
143
|
+
_deprecate("--list", "list")
|
|
144
|
+
return ["list"] + argv[1:]
|
|
145
|
+
|
|
146
|
+
# --add PATH ... -> `add PATH ...`
|
|
147
|
+
if "--add" in argv:
|
|
148
|
+
idx = argv.index("--add")
|
|
149
|
+
if idx + 1 < len(argv):
|
|
150
|
+
target = argv[idx + 1]
|
|
151
|
+
_deprecate("--add", "add")
|
|
152
|
+
rest = argv[:idx] + argv[idx + 2 :]
|
|
153
|
+
return ["add", target] + rest
|
|
154
|
+
|
|
155
|
+
# --remove PATH ... -> `rm PATH ...`
|
|
156
|
+
if "--remove" in argv:
|
|
157
|
+
idx = argv.index("--remove")
|
|
158
|
+
if idx + 1 < len(argv):
|
|
159
|
+
target = argv[idx + 1]
|
|
160
|
+
_deprecate("--remove", "rm")
|
|
161
|
+
rest = argv[:idx] + argv[idx + 2 :]
|
|
162
|
+
return ["rm", target] + rest
|
|
163
|
+
|
|
164
|
+
return argv
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _deprecate(old: str, new_verb: str) -> None:
|
|
168
|
+
print(
|
|
169
|
+
f"nostos: warning: {old} is deprecated; use `nostos {new_verb}` instead",
|
|
170
|
+
file=sys.stderr,
|
|
171
|
+
flush=True,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _inject_default_verb(argv: list[str]) -> list[str]:
|
|
176
|
+
"""If the first positional arg is not a known verb, prepend `pull`."""
|
|
177
|
+
for a in argv:
|
|
178
|
+
if a in {"-h", "--help", "-v", "--version"}:
|
|
179
|
+
return argv
|
|
180
|
+
if not a.startswith("-"):
|
|
181
|
+
if a in _KNOWN_VERBS:
|
|
182
|
+
return argv
|
|
183
|
+
return ["pull"] + argv
|
|
184
|
+
# All flags, no positional -> implicit pull
|
|
185
|
+
return ["pull"] + argv
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def run(argv: list[str] | None = None) -> int:
|
|
189
|
+
if argv is None:
|
|
190
|
+
argv = sys.argv[1:]
|
|
191
|
+
argv = _rewrite_legacy_argv(list(argv))
|
|
192
|
+
argv = _inject_default_verb(argv)
|
|
193
|
+
|
|
194
|
+
parser = build_parser()
|
|
195
|
+
|
|
196
|
+
# Optional shell tab-completion. Only active if argcomplete is installed
|
|
197
|
+
# and the shell invoked us in completion mode. Silently no-ops otherwise,
|
|
198
|
+
# so we don't make argcomplete a hard dependency.
|
|
199
|
+
try:
|
|
200
|
+
import argcomplete # type: ignore[import-not-found]
|
|
201
|
+
|
|
202
|
+
argcomplete.autocomplete(parser)
|
|
203
|
+
except ImportError:
|
|
204
|
+
pass
|
|
205
|
+
|
|
206
|
+
args = parser.parse_args(argv)
|
|
207
|
+
|
|
208
|
+
func = getattr(args, "func", None)
|
|
209
|
+
if func is None:
|
|
210
|
+
parser.print_help()
|
|
211
|
+
return 2
|
|
212
|
+
return int(func(args))
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# Back-compat: keep the old parse_args() symbol for anyone importing it.
|
|
216
|
+
def parse_args() -> argparse.Namespace:
|
|
217
|
+
"""Legacy entry used by older tests. Returns a Namespace for the
|
|
218
|
+
rewritten argv, without executing the command."""
|
|
219
|
+
argv = _rewrite_legacy_argv(list(sys.argv[1:]))
|
|
220
|
+
argv = _inject_default_verb(argv)
|
|
221
|
+
return build_parser().parse_args(argv)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def main_entry() -> None:
|
|
225
|
+
"""Entry point for the pip-installed `nostos` console script."""
|
|
226
|
+
sys.exit(run(sys.argv[1:]))
|
core/commands/_common.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Shared helpers for subcommand modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def fail(msg: str, *, code: int = 1) -> int:
|
|
10
|
+
"""Print an error to stderr and return an exit code."""
|
|
11
|
+
print(f"nostos: error: {msg}", file=sys.stderr, flush=True)
|
|
12
|
+
return code
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def maybe_migrate_watchlist() -> None:
|
|
16
|
+
"""One-shot migration from ~/.nostos_repos into the index.
|
|
17
|
+
|
|
18
|
+
If the legacy watchlist file still exists (and has not already been
|
|
19
|
+
renamed to .migrated), its entries are imported into the index with
|
|
20
|
+
source='legacy-watchlist' and status='reviewed'. The file is then
|
|
21
|
+
renamed to ~/.nostos_repos.migrated and a one-line notice is
|
|
22
|
+
printed to stderr.
|
|
23
|
+
"""
|
|
24
|
+
legacy = os.path.join(os.path.expanduser("~"), ".nostos_repos")
|
|
25
|
+
if not os.path.isfile(legacy):
|
|
26
|
+
return
|
|
27
|
+
from .. import index as _index
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
with _index.connect() as conn:
|
|
31
|
+
n = _index.migrate_watchlist(conn, legacy)
|
|
32
|
+
except OSError:
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
os.rename(legacy, legacy + ".migrated")
|
|
37
|
+
except OSError:
|
|
38
|
+
# Rename failed - leave the file but don't re-migrate next run
|
|
39
|
+
# by rewriting it with a header comment. Best-effort.
|
|
40
|
+
try:
|
|
41
|
+
with open(legacy, "w", encoding="utf-8") as f:
|
|
42
|
+
f.write("# migrated into nostos index; safe to delete\n")
|
|
43
|
+
except OSError:
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
print(
|
|
47
|
+
f"nostos: migrated {n} watchlist entries into the metadata index "
|
|
48
|
+
f"({legacy} -> {legacy}.migrated)",
|
|
49
|
+
file=sys.stderr,
|
|
50
|
+
flush=True,
|
|
51
|
+
)
|
core/commands/add.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""`nostos add` - ingest a repo into the metadata index.
|
|
2
|
+
|
|
3
|
+
Accepts either a local path to an existing git repository or a remote
|
|
4
|
+
URL. When given a URL, the repo is cloned first (via the hardened
|
|
5
|
+
clone routine, with hooks disabled) and then the resulting local path
|
|
6
|
+
is registered.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .. import index as _index
|
|
17
|
+
from ..config import load_config
|
|
18
|
+
from ..watchlist import clone_repo, is_remote_url
|
|
19
|
+
from ._common import fail, maybe_migrate_watchlist
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def add_parser(subparsers: Any) -> None:
|
|
23
|
+
p = subparsers.add_parser(
|
|
24
|
+
"add",
|
|
25
|
+
help="Register a repository in the metadata index",
|
|
26
|
+
description="Register a local git repository, or clone a remote URL and register it.",
|
|
27
|
+
)
|
|
28
|
+
p.add_argument(
|
|
29
|
+
"target",
|
|
30
|
+
metavar="PATH_OR_URL",
|
|
31
|
+
help="Local path to a git repository, or a remote URL to clone",
|
|
32
|
+
)
|
|
33
|
+
p.add_argument(
|
|
34
|
+
"--tag",
|
|
35
|
+
action="append",
|
|
36
|
+
default=[],
|
|
37
|
+
metavar="TAG",
|
|
38
|
+
help="Tag to attach (repeatable, or comma-separated)",
|
|
39
|
+
)
|
|
40
|
+
p.add_argument(
|
|
41
|
+
"--source",
|
|
42
|
+
default=None,
|
|
43
|
+
help="Free-text provenance (e.g. 'blog:orange.tw, 2026-04-12')",
|
|
44
|
+
)
|
|
45
|
+
p.add_argument(
|
|
46
|
+
"--note",
|
|
47
|
+
default=None,
|
|
48
|
+
help="Initial free-text note",
|
|
49
|
+
)
|
|
50
|
+
p.add_argument(
|
|
51
|
+
"--status",
|
|
52
|
+
default="new",
|
|
53
|
+
choices=sorted(_index.VALID_STATUSES),
|
|
54
|
+
help="Initial triage status (default: new)",
|
|
55
|
+
)
|
|
56
|
+
p.add_argument(
|
|
57
|
+
"--quiet-upstream",
|
|
58
|
+
action="store_true",
|
|
59
|
+
help="Opsec flag: never query upstream metadata for this repo",
|
|
60
|
+
)
|
|
61
|
+
p.add_argument(
|
|
62
|
+
"--clone-dir",
|
|
63
|
+
default=None,
|
|
64
|
+
metavar="DIR",
|
|
65
|
+
help="Directory to clone into when target is a URL (default: cwd or config)",
|
|
66
|
+
)
|
|
67
|
+
p.set_defaults(func=run)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _flatten_tags(raw: list[str]) -> list[str]:
|
|
71
|
+
tags: list[str] = []
|
|
72
|
+
for item in raw:
|
|
73
|
+
for piece in item.split(","):
|
|
74
|
+
piece = piece.strip()
|
|
75
|
+
if piece:
|
|
76
|
+
tags.append(piece)
|
|
77
|
+
return tags
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def run(args: argparse.Namespace) -> int:
|
|
81
|
+
maybe_migrate_watchlist()
|
|
82
|
+
|
|
83
|
+
target = args.target
|
|
84
|
+
tags = _flatten_tags(args.tag)
|
|
85
|
+
|
|
86
|
+
if is_remote_url(target):
|
|
87
|
+
clone_dir = args.clone_dir
|
|
88
|
+
if clone_dir is None:
|
|
89
|
+
clone_dir = load_config()["clone_dir"] or os.getcwd()
|
|
90
|
+
local_path = clone_repo(target, clone_dir)
|
|
91
|
+
if local_path is None:
|
|
92
|
+
return fail(f"clone failed: {target}")
|
|
93
|
+
repo_path = local_path
|
|
94
|
+
remote_url = target
|
|
95
|
+
else:
|
|
96
|
+
repo_path = target
|
|
97
|
+
if not os.path.isdir(os.path.join(os.path.expanduser(repo_path), ".git")):
|
|
98
|
+
return fail(f"not a git repository: {repo_path}")
|
|
99
|
+
remote_url = None
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
with _index.connect() as conn:
|
|
103
|
+
repo_id = _index.add_repo(
|
|
104
|
+
conn,
|
|
105
|
+
repo_path,
|
|
106
|
+
remote_url=remote_url,
|
|
107
|
+
source=args.source,
|
|
108
|
+
status=args.status,
|
|
109
|
+
quiet=args.quiet_upstream,
|
|
110
|
+
tags=tags,
|
|
111
|
+
note=args.note,
|
|
112
|
+
)
|
|
113
|
+
except (OSError, ValueError) as e:
|
|
114
|
+
return fail(str(e))
|
|
115
|
+
|
|
116
|
+
print(f"Added to index (id={repo_id}): {os.path.realpath(os.path.expanduser(repo_path))}",
|
|
117
|
+
file=sys.stderr, flush=True)
|
|
118
|
+
return 0
|
core/commands/attack.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""`nostos attack` - ATT&CK technique helpers.
|
|
2
|
+
|
|
3
|
+
Sub-verbs:
|
|
4
|
+
- `attack list` print the built-in technique lookup table.
|
|
5
|
+
- `attack tag <repo> T1059 [T1071 ...]` shorthand for
|
|
6
|
+
`nostos tag <repo> +attack:t1059 +attack:t1071`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import sys
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .. import index as _index
|
|
16
|
+
from .. import taxonomy as _tax
|
|
17
|
+
from ._common import fail, maybe_migrate_watchlist
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def add_parser(subparsers: Any) -> None:
|
|
21
|
+
p = subparsers.add_parser(
|
|
22
|
+
"attack",
|
|
23
|
+
help="ATT&CK technique helpers (list taxonomy, tag repos)",
|
|
24
|
+
description="Work with MITRE ATT&CK technique tags.",
|
|
25
|
+
)
|
|
26
|
+
sub = p.add_subparsers(dest="attack_command", metavar="SUBCOMMAND", required=True)
|
|
27
|
+
|
|
28
|
+
# attack list
|
|
29
|
+
lst = sub.add_parser(
|
|
30
|
+
"list",
|
|
31
|
+
help="Print the built-in ATT&CK technique lookup table",
|
|
32
|
+
)
|
|
33
|
+
lst.set_defaults(func=run_list)
|
|
34
|
+
|
|
35
|
+
# attack tag <repo> T1059 [T1071 ...]
|
|
36
|
+
tg = sub.add_parser(
|
|
37
|
+
"tag",
|
|
38
|
+
help="Tag a repo with one or more ATT&CK technique IDs",
|
|
39
|
+
description=(
|
|
40
|
+
"Shorthand for `nostos tag <repo> +attack:TNNNN`. "
|
|
41
|
+
"Each ID is validated against the built-in lookup table."
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
tg.add_argument("target", metavar="PATH_OR_ID")
|
|
45
|
+
tg.add_argument("techniques", nargs="+", metavar="TNNNN")
|
|
46
|
+
tg.set_defaults(func=run_tag)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def run_list(args: argparse.Namespace) -> int:
|
|
50
|
+
print(f"MITRE ATT&CK techniques ({len(_tax.TECHNIQUES)} in the built-in table):\n")
|
|
51
|
+
sys.stdout.write(_tax.render_table())
|
|
52
|
+
return 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def run_tag(args: argparse.Namespace) -> int:
|
|
56
|
+
maybe_migrate_watchlist()
|
|
57
|
+
|
|
58
|
+
tags_to_add: list[str] = []
|
|
59
|
+
for raw in args.techniques:
|
|
60
|
+
tag = _tax.normalize_attack_tag(raw)
|
|
61
|
+
info = _tax.lookup(tag)
|
|
62
|
+
if info is None:
|
|
63
|
+
print(
|
|
64
|
+
f"warning: {raw} is not in the built-in lookup table; "
|
|
65
|
+
f"adding as {tag} anyway",
|
|
66
|
+
file=sys.stderr,
|
|
67
|
+
)
|
|
68
|
+
tags_to_add.append(tag)
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
with _index.connect() as conn:
|
|
72
|
+
if not _index.add_tags(conn, args.target, tags_to_add):
|
|
73
|
+
return fail(f"not in index: {args.target}")
|
|
74
|
+
final = _index.get_tags(conn, args.target)
|
|
75
|
+
except OSError as e:
|
|
76
|
+
return fail(str(e))
|
|
77
|
+
|
|
78
|
+
attack_tags = [t for t in final if t.startswith("attack:")]
|
|
79
|
+
other_tags = [t for t in final if not t.startswith("attack:")]
|
|
80
|
+
print(f"attack tags: {', '.join(attack_tags) or '(none)'}", file=sys.stderr)
|
|
81
|
+
if other_tags:
|
|
82
|
+
print(f"other tags: {', '.join(other_tags)}", file=sys.stderr)
|
|
83
|
+
return 0
|