@kinosec/cli 0.10.2 → 0.11.1
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.
- package/dist/cli.js +286 -12
- package/dist/cli.js.map +1 -1
- package/dist/vendor/README.md +134 -0
- package/dist/vendor/ctxpack.py +701 -0
- package/package.json +14 -13
- package/vendor/README.md +134 -0
- package/vendor/ctxpack.py +701 -0
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""ctxpack -- source-context ingestion for graybox CTF/pentest agents.
|
|
3
|
+
|
|
4
|
+
Turns a black-box agent into a graybox one by packing a challenge's SOURCE CODE
|
|
5
|
+
into a single inject-ready bundle. The only real problem it solves is that source
|
|
6
|
+
archives are padded with non-logic files (CSS frameworks, images, fonts, vendored
|
|
7
|
+
deps, lockfiles, editor junk); a cheap extension/path FILTER -- not a code graph,
|
|
8
|
+
embeddings, or a whitebox agent -- is what makes "just paste the source" work.
|
|
9
|
+
|
|
10
|
+
Pipeline: FILTER (KEEP vs DROP) -> GATE (sum kept-source est-tokens vs budget)
|
|
11
|
+
-> ROUTE (paste bundle if it fits, else a bodiless retrieve outline) -> MANIFEST
|
|
12
|
+
(every included + every excluded file, with reasons; no silent truncation).
|
|
13
|
+
|
|
14
|
+
Metric: tokens of logic-bearing source, estimated as bytes / 3.7 (no tokenizer
|
|
15
|
+
dependency). Threshold: --budget (default 120000). Both are tunable.
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
ctxpack <SOURCE> [-o OUT] [--budget N] [--bytes-per-token F] [--stdout]
|
|
19
|
+
[--emit-context-file PATH] [--stage-retrieve] [--stage-dir DIR]
|
|
20
|
+
[--ctags] [--fork-diff [UPSTREAM_REF]]
|
|
21
|
+
|
|
22
|
+
Examples:
|
|
23
|
+
ctxpack ./challenge-src
|
|
24
|
+
ctxpack handout.zip -o /tmp/ctx --budget 120000
|
|
25
|
+
ctxpack ./big-repo --stage-retrieve --stage-dir /tmp/ctxpack_src
|
|
26
|
+
ctxpack . --fork-diff origin/main # emit planted delta vs upstream
|
|
27
|
+
|
|
28
|
+
Zero third-party dependencies -- Python standard library only.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
import argparse
|
|
32
|
+
import fnmatch
|
|
33
|
+
import json
|
|
34
|
+
import os
|
|
35
|
+
import re
|
|
36
|
+
import shutil
|
|
37
|
+
import subprocess
|
|
38
|
+
import sys
|
|
39
|
+
import tarfile
|
|
40
|
+
import tempfile
|
|
41
|
+
import zipfile
|
|
42
|
+
|
|
43
|
+
# ============================ CONFIG (edit freely) ============================
|
|
44
|
+
# Token estimate = bytes / BYTES_PER_TOKEN. The repo habit elsewhere is 4.0; we
|
|
45
|
+
# default to 3.7 (slightly more conservative) and expose --bytes-per-token.
|
|
46
|
+
BYTES_PER_TOKEN = 3.7
|
|
47
|
+
# Route paste vs retrieve on the sum of KEEP-file est-tokens (not raw bytes).
|
|
48
|
+
DEFAULT_BUDGET_TOKENS = 120_000
|
|
49
|
+
|
|
50
|
+
# --- DROP by extension -> reason group (checked case-insensitively). ---
|
|
51
|
+
DROP_EXT = {
|
|
52
|
+
"image": {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico"},
|
|
53
|
+
"font": {".woff", ".woff2", ".ttf", ".eot", ".otf"},
|
|
54
|
+
"style": {".css", ".scss", ".sass", ".less"},
|
|
55
|
+
"media": {".mp4", ".mp3", ".pdf"},
|
|
56
|
+
"binary": {".zip", ".tar", ".gz", ".so", ".a", ".o", ".pyc",
|
|
57
|
+
".class", ".wasm", ".bin"},
|
|
58
|
+
}
|
|
59
|
+
# Compound suffixes checked BEFORE single-extension rules: minified + sourcemaps.
|
|
60
|
+
DROP_SUFFIX_MINIFIED = (".min.js", ".min.css", ".map")
|
|
61
|
+
|
|
62
|
+
# --- DROP by exact basename -> reason. ---
|
|
63
|
+
DROP_FILENAMES = {
|
|
64
|
+
"package-lock.json": "lockfile", "yarn.lock": "lockfile",
|
|
65
|
+
"poetry.lock": "lockfile", "Pipfile.lock": "lockfile",
|
|
66
|
+
"Cargo.lock": "lockfile", "composer.lock": "lockfile", "go.sum": "lockfile",
|
|
67
|
+
".DS_Store": "editor-junk", "Thumbs.db": "editor-junk",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# --- DROP by path segment (any path component match) -> reason path:<seg>. ---
|
|
71
|
+
DROP_PATH_SEGMENTS = {
|
|
72
|
+
"node_modules", "vendor", "dist", "build", "__pycache__", "third_party", ".git",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
# --- KEEP hints. Anything not DROPped is kept regardless; these drive the
|
|
76
|
+
# logic-first ORDERING (and document intent). ---
|
|
77
|
+
KEEP_SOURCE_EXT = {
|
|
78
|
+
".py", ".js", ".ts", ".go", ".rb", ".php", ".java", ".c", ".cpp", ".h",
|
|
79
|
+
".rs", ".sh", ".jsx", ".tsx", ".cc", ".hpp", ".pyi", ".mjs", ".cjs",
|
|
80
|
+
}
|
|
81
|
+
KEEP_TEMPLATE_EXT = {
|
|
82
|
+
".html", ".htm", ".jinja", ".j2", ".ejs", ".erb", ".twig", ".mako",
|
|
83
|
+
".hbs", ".vue", ".svelte",
|
|
84
|
+
}
|
|
85
|
+
# Behavior-carrying config: exact names + glob patterns (matched on basename).
|
|
86
|
+
KEEP_CONFIG_NAMES = {"docker-compose.yml", "docker-compose.yaml", "package.json"}
|
|
87
|
+
KEEP_CONFIG_GLOBS = ("Dockerfile*", "docker-compose.y*ml", "*.conf",
|
|
88
|
+
"requirements*.txt", "nginx*.conf", "supervisor*.conf",
|
|
89
|
+
"*.env.example")
|
|
90
|
+
|
|
91
|
+
# Entrypoint/route/service hints -> logic-first tier 1 (matched as path tokens).
|
|
92
|
+
ENTRYPOINT_HINTS = {
|
|
93
|
+
"app", "main", "index", "server", "wsgi", "asgi", "manage", "run",
|
|
94
|
+
"route", "routes", "view", "views", "controller", "controllers",
|
|
95
|
+
"handler", "handlers", "api", "service", "services", "url", "urls",
|
|
96
|
+
"endpoint", "endpoints", "middleware",
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
# Known-CTF marker filename tokens + content regex (SIGNAL only; never acted on).
|
|
100
|
+
CTF_MARKER_TOKENS = {"flag", "flags", "challenge", "benchmark", "solve",
|
|
101
|
+
"solver", "exploit", "writeup"}
|
|
102
|
+
CTF_CONTENT_RE = re.compile(rb"(FLAG|CTF|HTB|flag)\{")
|
|
103
|
+
# ============================================================================
|
|
104
|
+
|
|
105
|
+
TIER_RANK = {"entrypoint": 0, "source": 1, "template": 2, "config": 3, "other": 4}
|
|
106
|
+
_TOKEN_RE = re.compile(r"[^a-z0-9]+")
|
|
107
|
+
_CHUNK = 8192
|
|
108
|
+
# Printable text bytes for the binary-content heuristic.
|
|
109
|
+
_TEXT_BYTES = bytes(range(0x20, 0x7F)) + b"\n\r\t\f\b\x1b"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _dbg(on, msg):
|
|
113
|
+
"""Per-decision trace to stderr (opt-in via --debug) so the filter is
|
|
114
|
+
auditable mid-event: exactly which files were kept/dropped and why."""
|
|
115
|
+
if on:
|
|
116
|
+
sys.stderr.write("[ctxpack] " + msg + "\n")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ------------------------------- classification -----------------------------
|
|
120
|
+
def _path_tokens(rel_path):
|
|
121
|
+
return {t for t in _TOKEN_RE.split(rel_path.lower()) if t}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _is_config(name):
|
|
125
|
+
if name in KEEP_CONFIG_NAMES:
|
|
126
|
+
return True
|
|
127
|
+
return any(fnmatch.fnmatchcase(name, pat) or fnmatch.fnmatchcase(name.lower(), pat.lower())
|
|
128
|
+
for pat in KEEP_CONFIG_GLOBS)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def classify(rel_path):
|
|
132
|
+
"""Partition a file into KEEP or DROP.
|
|
133
|
+
|
|
134
|
+
Returns (keep: bool, tag: str). For DROP, tag is the reason code. For KEEP,
|
|
135
|
+
tag is the ordering tier (entrypoint/source/template/config/other).
|
|
136
|
+
Check order per spec: exact filename -> minified suffix -> extension ->
|
|
137
|
+
path segment -> else KEEP.
|
|
138
|
+
"""
|
|
139
|
+
parts = rel_path.replace("\\", "/").split("/")
|
|
140
|
+
name = parts[-1]
|
|
141
|
+
lname = name.lower()
|
|
142
|
+
|
|
143
|
+
if name in DROP_FILENAMES:
|
|
144
|
+
return False, DROP_FILENAMES[name]
|
|
145
|
+
|
|
146
|
+
for suf in DROP_SUFFIX_MINIFIED:
|
|
147
|
+
if lname.endswith(suf):
|
|
148
|
+
return False, "ext:minified"
|
|
149
|
+
|
|
150
|
+
ext = os.path.splitext(lname)[1]
|
|
151
|
+
for group, exts in DROP_EXT.items():
|
|
152
|
+
if ext in exts:
|
|
153
|
+
return False, "ext:" + group
|
|
154
|
+
|
|
155
|
+
for seg in parts:
|
|
156
|
+
if seg in DROP_PATH_SEGMENTS:
|
|
157
|
+
return False, "path:" + seg
|
|
158
|
+
|
|
159
|
+
# KEEP -- assign a logic-first tier.
|
|
160
|
+
tokens = _path_tokens(rel_path)
|
|
161
|
+
if ext in KEEP_SOURCE_EXT:
|
|
162
|
+
return True, "entrypoint" if tokens & ENTRYPOINT_HINTS else "source"
|
|
163
|
+
if ext in KEEP_TEMPLATE_EXT:
|
|
164
|
+
return True, "template"
|
|
165
|
+
if _is_config(name):
|
|
166
|
+
return True, "config"
|
|
167
|
+
return True, "other"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def looks_binary(chunk):
|
|
171
|
+
"""Classic file(1)/git heuristic: NUL byte, or >30% non-text bytes."""
|
|
172
|
+
if not chunk:
|
|
173
|
+
return False
|
|
174
|
+
if b"\x00" in chunk:
|
|
175
|
+
return True
|
|
176
|
+
nontext = chunk.translate(None, _TEXT_BYTES)
|
|
177
|
+
return len(nontext) / len(chunk) > 0.30
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# --------------------------------- scanning ---------------------------------
|
|
181
|
+
def _iter_files(root):
|
|
182
|
+
"""Yield relative POSIX paths under root, deterministically, not following
|
|
183
|
+
symlinked directories."""
|
|
184
|
+
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
|
|
185
|
+
dirnames.sort()
|
|
186
|
+
for fn in sorted(filenames):
|
|
187
|
+
ap = os.path.join(dirpath, fn)
|
|
188
|
+
if os.path.islink(ap):
|
|
189
|
+
continue # skip symlinked files (avoid escaping the tree)
|
|
190
|
+
rel = os.path.relpath(ap, root).replace(os.sep, "/")
|
|
191
|
+
yield rel, ap
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def detect_stack(root, included):
|
|
195
|
+
"""Best-effort web-framework fingerprint of the KEEP set, so a graybox launcher can
|
|
196
|
+
sanity-check the bundle against the live target before spending a run. Returns a short
|
|
197
|
+
lowercase name ("nextjs", "react", "express", "flask", "django", "php", ...) or "" when
|
|
198
|
+
nothing is recognized. Heuristic and intentionally conservative (only positive signals)."""
|
|
199
|
+
lowers = [r["path"].lower() for r in included]
|
|
200
|
+
bases = {os.path.basename(p) for p in lowers}
|
|
201
|
+
|
|
202
|
+
def _head(rel):
|
|
203
|
+
try:
|
|
204
|
+
with open(os.path.join(root, rel), "rb") as fh:
|
|
205
|
+
return fh.read(_CHUNK).decode("utf-8", "replace").lower()
|
|
206
|
+
except OSError:
|
|
207
|
+
return ""
|
|
208
|
+
|
|
209
|
+
# --- Node/JS family: trust package.json dependencies first.
|
|
210
|
+
pkg_rel = next((r["path"] for r in included
|
|
211
|
+
if os.path.basename(r["path"]).lower() == "package.json"), None)
|
|
212
|
+
has_next_cfg = any(b.startswith("next.config.") for b in bases)
|
|
213
|
+
if pkg_rel:
|
|
214
|
+
txt = _head(pkg_rel)
|
|
215
|
+
if '"next"' in txt or has_next_cfg:
|
|
216
|
+
return "nextjs"
|
|
217
|
+
if '"express"' in txt:
|
|
218
|
+
return "express"
|
|
219
|
+
if '"@angular/core"' in txt:
|
|
220
|
+
return "angular"
|
|
221
|
+
if '"vue"' in txt:
|
|
222
|
+
return "vue"
|
|
223
|
+
if '"svelte"' in txt:
|
|
224
|
+
return "svelte"
|
|
225
|
+
if ('"react"' in txt or '"react-dom"' in txt
|
|
226
|
+
or any(p.endswith((".tsx", ".jsx")) for p in lowers)):
|
|
227
|
+
return "react"
|
|
228
|
+
return "node"
|
|
229
|
+
if has_next_cfg:
|
|
230
|
+
return "nextjs"
|
|
231
|
+
|
|
232
|
+
# --- Python family: requirements first, then a bounded source sniff.
|
|
233
|
+
req_rels = [r["path"] for r in included
|
|
234
|
+
if os.path.basename(r["path"]).lower().startswith("requirements")]
|
|
235
|
+
reqtext = " ".join(_head(p) for p in req_rels)
|
|
236
|
+
if "manage.py" in bases or "django" in reqtext:
|
|
237
|
+
return "django"
|
|
238
|
+
if "fastapi" in reqtext:
|
|
239
|
+
return "fastapi"
|
|
240
|
+
if "flask" in reqtext:
|
|
241
|
+
return "flask"
|
|
242
|
+
sniffed = 0
|
|
243
|
+
for r in included:
|
|
244
|
+
if sniffed >= 20:
|
|
245
|
+
break
|
|
246
|
+
if r["path"].lower().endswith(".py"):
|
|
247
|
+
sniffed += 1
|
|
248
|
+
t = _head(r["path"])
|
|
249
|
+
if "from flask" in t or "import flask" in t:
|
|
250
|
+
return "flask"
|
|
251
|
+
if "from fastapi" in t or "import fastapi" in t:
|
|
252
|
+
return "fastapi"
|
|
253
|
+
if "from django" in t or "import django" in t:
|
|
254
|
+
return "django"
|
|
255
|
+
|
|
256
|
+
# --- Other ecosystems (coarse).
|
|
257
|
+
if any(p.endswith(".php") for p in lowers):
|
|
258
|
+
return "php"
|
|
259
|
+
if "gemfile" in bases or any(p.endswith(".rb") for p in lowers):
|
|
260
|
+
return "rails"
|
|
261
|
+
return ""
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def scan(root, bytes_per_token, debug=False):
|
|
265
|
+
"""Walk root, classify every file, and collect included/excluded records
|
|
266
|
+
plus signal evidence. Returns a dict."""
|
|
267
|
+
included, excluded = [], []
|
|
268
|
+
git_present = os.path.isdir(os.path.join(root, ".git"))
|
|
269
|
+
ctf_markers = set()
|
|
270
|
+
binary_artifact_seen = False
|
|
271
|
+
|
|
272
|
+
for rel, ap in _iter_files(root):
|
|
273
|
+
try:
|
|
274
|
+
size = os.path.getsize(ap)
|
|
275
|
+
except OSError:
|
|
276
|
+
excluded.append({"path": rel, "bytes": 0, "reason": "unreadable"})
|
|
277
|
+
_dbg(debug, "DROP unreadable {}".format(rel))
|
|
278
|
+
continue
|
|
279
|
+
|
|
280
|
+
base_tokens = _path_tokens(os.path.basename(rel))
|
|
281
|
+
if base_tokens & CTF_MARKER_TOKENS:
|
|
282
|
+
ctf_markers.add("name:" + rel)
|
|
283
|
+
|
|
284
|
+
keep, tag = classify(rel)
|
|
285
|
+
if not keep:
|
|
286
|
+
if tag == "ext:binary":
|
|
287
|
+
binary_artifact_seen = True
|
|
288
|
+
if ".git" in rel.split("/"):
|
|
289
|
+
git_present = True
|
|
290
|
+
excluded.append({"path": rel, "bytes": size, "reason": tag})
|
|
291
|
+
_dbg(debug, "DROP {:<17} {}".format(tag, rel))
|
|
292
|
+
continue
|
|
293
|
+
|
|
294
|
+
# KEEP candidate: read head once for binary + CTF-content detection.
|
|
295
|
+
try:
|
|
296
|
+
with open(ap, "rb") as fh:
|
|
297
|
+
head = fh.read(_CHUNK)
|
|
298
|
+
except OSError:
|
|
299
|
+
excluded.append({"path": rel, "bytes": size, "reason": "unreadable"})
|
|
300
|
+
_dbg(debug, "DROP unreadable {}".format(rel))
|
|
301
|
+
continue
|
|
302
|
+
|
|
303
|
+
if looks_binary(head):
|
|
304
|
+
binary_artifact_seen = True
|
|
305
|
+
excluded.append({"path": rel, "bytes": size, "reason": "binary-content"})
|
|
306
|
+
_dbg(debug, "DROP binary-content {}".format(rel))
|
|
307
|
+
continue
|
|
308
|
+
|
|
309
|
+
if CTF_CONTENT_RE.search(head):
|
|
310
|
+
ctf_markers.add("content:" + rel)
|
|
311
|
+
|
|
312
|
+
rec = {
|
|
313
|
+
"path": rel,
|
|
314
|
+
"bytes": size,
|
|
315
|
+
"est_tokens": round(size / bytes_per_token),
|
|
316
|
+
"tier": tag,
|
|
317
|
+
}
|
|
318
|
+
if tag == "other":
|
|
319
|
+
rec["note"] = "kept:default-unknown-ext"
|
|
320
|
+
included.append(rec)
|
|
321
|
+
_dbg(debug, "KEEP {:<17} {}".format(tag, rel))
|
|
322
|
+
|
|
323
|
+
binaries_only = (not included) and binary_artifact_seen
|
|
324
|
+
_dbg(debug, "scan done: {} kept, {} dropped, {} scanned".format(
|
|
325
|
+
len(included), len(excluded), len(included) + len(excluded)))
|
|
326
|
+
return {
|
|
327
|
+
"included": included,
|
|
328
|
+
"excluded": excluded,
|
|
329
|
+
"signals": {
|
|
330
|
+
"git_present": bool(git_present),
|
|
331
|
+
"binaries_only": bool(binaries_only),
|
|
332
|
+
"known_ctf_markers": sorted(ctf_markers),
|
|
333
|
+
"stack": detect_stack(root, included),
|
|
334
|
+
},
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# --------------------------------- rendering --------------------------------
|
|
339
|
+
def _order_logic_first(included):
|
|
340
|
+
return sorted(included, key=lambda r: (TIER_RANK.get(r["tier"], 9), r["path"]))
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _read_text(ap):
|
|
344
|
+
with open(ap, "rb") as fh:
|
|
345
|
+
return fh.read().decode("utf-8", errors="replace")
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def render_paste(root, included, kept_tokens, budget, source_label):
|
|
349
|
+
ordered = _order_logic_first(included)
|
|
350
|
+
pct = (100 * kept_tokens // budget) if budget else 0
|
|
351
|
+
out = [
|
|
352
|
+
"# ctxpack bundle -- mode=paste -- {n} files, ~{t} tokens "
|
|
353
|
+
"(budget {b}, ~{p}%) -- {s}".format(
|
|
354
|
+
n=len(ordered), t=kept_tokens, b=budget, p=pct, s=source_label),
|
|
355
|
+
"# Each file is prefixed with '===== <relative/path> ====='. "
|
|
356
|
+
"Ordering: entrypoints/routes/services, then source, templates, config.",
|
|
357
|
+
"",
|
|
358
|
+
]
|
|
359
|
+
for rec in ordered:
|
|
360
|
+
out.append("===== {} =====".format(rec["path"]))
|
|
361
|
+
out.append(_read_text(os.path.join(root, rec["path"].replace("/", os.sep))))
|
|
362
|
+
out.append("")
|
|
363
|
+
return "\n".join(out)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _build_tree(paths):
|
|
367
|
+
tree = {}
|
|
368
|
+
for p in paths:
|
|
369
|
+
node = tree
|
|
370
|
+
for part in p.split("/"):
|
|
371
|
+
node = node.setdefault(part, {})
|
|
372
|
+
lines = []
|
|
373
|
+
|
|
374
|
+
def walk(node, prefix=""):
|
|
375
|
+
entries = sorted(node.items())
|
|
376
|
+
for i, (name, child) in enumerate(entries):
|
|
377
|
+
last = i == len(entries) - 1
|
|
378
|
+
lines.append(prefix + ("└── " if last else "├── ") + name)
|
|
379
|
+
if child:
|
|
380
|
+
walk(child, prefix + (" " if last else "│ "))
|
|
381
|
+
|
|
382
|
+
walk(tree)
|
|
383
|
+
return "\n".join(lines)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def render_retrieve(included, kept_tokens, budget, source_label, staged_dir=None):
|
|
387
|
+
ordered = _order_logic_first(included)
|
|
388
|
+
where = (" Files staged under {} -- grep/read there.".format(staged_dir)
|
|
389
|
+
if staged_dir else "")
|
|
390
|
+
out = [
|
|
391
|
+
"# ctxpack outline -- mode=retrieve -- kept ~{t} tokens > budget {b} -- {s}".format(
|
|
392
|
+
t=kept_tokens, b=budget, s=source_label),
|
|
393
|
+
"# NO file bodies below.{} Grep/read these {} paths on demand.".format(
|
|
394
|
+
where, len(ordered)),
|
|
395
|
+
"",
|
|
396
|
+
"## FILE TREE (KEEP set)",
|
|
397
|
+
_build_tree([r["path"] for r in ordered]),
|
|
398
|
+
"",
|
|
399
|
+
"## KEEP PATHS (logic-first; path<TAB>bytes<TAB>est_tokens)",
|
|
400
|
+
]
|
|
401
|
+
for rec in ordered:
|
|
402
|
+
out.append("{}\t{}\t{}".format(rec["path"], rec["bytes"], rec["est_tokens"]))
|
|
403
|
+
return "\n".join(out)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def render_manifest_md(manifest):
|
|
407
|
+
m = manifest
|
|
408
|
+
t = m["totals"]
|
|
409
|
+
sig = m["signals"]
|
|
410
|
+
lines = [
|
|
411
|
+
"# ctxpack manifest",
|
|
412
|
+
"",
|
|
413
|
+
"- **source:** `{}` ({})".format(m["source"], m["source_type"]),
|
|
414
|
+
"- **mode:** `{}` (fits budget: {})".format(m["mode"], m["fits"]),
|
|
415
|
+
"- **budget:** {} tokens **bytes/token:** {}".format(
|
|
416
|
+
m["budget_tokens"], m["bytes_per_token"]),
|
|
417
|
+
"- **kept source:** {} files, {} bytes, ~{} tokens".format(
|
|
418
|
+
t["included_files"], t["included_bytes"], t["included_est_tokens"]),
|
|
419
|
+
"- **excluded:** {} files, {} bytes".format(
|
|
420
|
+
t["excluded_files"], t["excluded_bytes"]),
|
|
421
|
+
"- **scanned total:** {} files".format(t["scanned_files"]),
|
|
422
|
+
"",
|
|
423
|
+
"## Signals",
|
|
424
|
+
"- git_present: {}".format(sig["git_present"]),
|
|
425
|
+
"- binaries_only: {}".format(sig["binaries_only"]),
|
|
426
|
+
"- stack: {}".format(sig.get("stack") or "(unknown)"),
|
|
427
|
+
"- known_ctf_markers: {}".format(", ".join(sig["known_ctf_markers"]) or "(none)"),
|
|
428
|
+
"",
|
|
429
|
+
"## Included ({}) -- path | bytes | est_tokens | tier".format(t["included_files"]),
|
|
430
|
+
]
|
|
431
|
+
for r in _order_logic_first(m["included"]):
|
|
432
|
+
note = " _({})_".format(r["note"]) if r.get("note") else ""
|
|
433
|
+
lines.append("- `{}` | {} | {} | {}{}".format(
|
|
434
|
+
r["path"], r["bytes"], r["est_tokens"], r["tier"], note))
|
|
435
|
+
lines += ["", "## Excluded ({}) -- path | bytes | reason".format(t["excluded_files"])]
|
|
436
|
+
for r in sorted(m["excluded"], key=lambda x: (x["reason"], x["path"])):
|
|
437
|
+
lines.append("- `{}` | {} | {}".format(r["path"], r["bytes"], r["reason"]))
|
|
438
|
+
lines.append("")
|
|
439
|
+
return "\n".join(lines)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
# ------------------------------ archive input -------------------------------
|
|
443
|
+
def _validate_member(dest, member_name):
|
|
444
|
+
dest_abs = os.path.abspath(dest)
|
|
445
|
+
target = os.path.abspath(os.path.join(dest, member_name))
|
|
446
|
+
if target != dest_abs and not target.startswith(dest_abs + os.sep):
|
|
447
|
+
raise ValueError("unsafe path in archive: {!r}".format(member_name))
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def extract_archive(path, dest, zip_password=None):
|
|
451
|
+
"""Safely extract a .zip/.tar[.gz/.bz2/.xz] into dest. Returns 'zip'/'tar'.
|
|
452
|
+
Password-encrypted zips need zip_password (stdlib supports classic ZipCrypto
|
|
453
|
+
only, not WinZip-AES)."""
|
|
454
|
+
if zipfile.is_zipfile(path):
|
|
455
|
+
with zipfile.ZipFile(path) as zf:
|
|
456
|
+
for n in zf.namelist():
|
|
457
|
+
_validate_member(dest, n)
|
|
458
|
+
encrypted = any(zi.flag_bits & 0x1 for zi in zf.infolist())
|
|
459
|
+
if encrypted and not zip_password:
|
|
460
|
+
raise ValueError("zip is password-encrypted; pass --zip-password PW "
|
|
461
|
+
"(stdlib supports classic ZipCrypto, not AES)")
|
|
462
|
+
pwd = zip_password.encode() if zip_password else None
|
|
463
|
+
try:
|
|
464
|
+
zf.extractall(dest, pwd=pwd)
|
|
465
|
+
except RuntimeError as e:
|
|
466
|
+
raise ValueError("zip extraction failed (wrong password?): {}".format(e))
|
|
467
|
+
return "zip"
|
|
468
|
+
if tarfile.is_tarfile(path):
|
|
469
|
+
with tarfile.open(path) as tf:
|
|
470
|
+
try:
|
|
471
|
+
tf.extractall(dest, filter="data") # py>=3.12 safe filter
|
|
472
|
+
except TypeError:
|
|
473
|
+
for m in tf.getmembers():
|
|
474
|
+
_validate_member(dest, m.name)
|
|
475
|
+
tf.extractall(dest)
|
|
476
|
+
return "tar"
|
|
477
|
+
raise ValueError("not a directory or supported archive: {}".format(path))
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
# --------------------------------- stretch ----------------------------------
|
|
481
|
+
def _stage_files(root, included, stage_dir, clean):
|
|
482
|
+
"""Copy KEEP files into stage_dir (preserving relative layout). Only wipe the
|
|
483
|
+
dir first when it is the tool-owned default (clean=True); NEVER delete a
|
|
484
|
+
user-supplied --stage-dir wholesale (it is overlaid, not cleared)."""
|
|
485
|
+
if clean and os.path.isdir(stage_dir):
|
|
486
|
+
shutil.rmtree(stage_dir)
|
|
487
|
+
os.makedirs(stage_dir, exist_ok=True)
|
|
488
|
+
for rec in included:
|
|
489
|
+
src = os.path.join(root, rec["path"].replace("/", os.sep))
|
|
490
|
+
dst = os.path.join(stage_dir, rec["path"].replace("/", os.sep))
|
|
491
|
+
os.makedirs(os.path.dirname(dst) or stage_dir, exist_ok=True)
|
|
492
|
+
shutil.copy2(src, dst)
|
|
493
|
+
return stage_dir
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _run_ctags(stage_dir, out_dir):
|
|
497
|
+
if not shutil.which("ctags"):
|
|
498
|
+
return {"ctags": "skipped: ctags not found on PATH"}
|
|
499
|
+
tags_path = os.path.join(out_dir, "tags")
|
|
500
|
+
try:
|
|
501
|
+
subprocess.run(["ctags", "-R", "-f", tags_path, stage_dir],
|
|
502
|
+
check=True, capture_output=True)
|
|
503
|
+
return {"ctags": os.path.relpath(tags_path, out_dir)}
|
|
504
|
+
except subprocess.CalledProcessError as e:
|
|
505
|
+
return {"ctags": "failed: {}".format(e.stderr.decode("utf-8", "replace")[:200])}
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _fork_diff_bundle(source_dir, upstream_ref, source_label):
|
|
509
|
+
"""Emit git log + diff vs upstream instead of the full bundle. Degrades to
|
|
510
|
+
None (caller falls back to a normal bundle) if not a usable git repo."""
|
|
511
|
+
if not shutil.which("git") or not os.path.isdir(os.path.join(source_dir, ".git")):
|
|
512
|
+
return None
|
|
513
|
+
|
|
514
|
+
def git(*args):
|
|
515
|
+
return subprocess.run(["git", "-C", source_dir, *args],
|
|
516
|
+
capture_output=True, text=True).stdout.strip()
|
|
517
|
+
|
|
518
|
+
ref = upstream_ref if isinstance(upstream_ref, str) else ""
|
|
519
|
+
if not ref:
|
|
520
|
+
for cand in ("@{upstream}", "origin/HEAD", "origin/main", "origin/master", "main"):
|
|
521
|
+
if git("rev-parse", "--verify", "--quiet", cand):
|
|
522
|
+
ref = cand
|
|
523
|
+
break
|
|
524
|
+
if not ref:
|
|
525
|
+
return None
|
|
526
|
+
log = git("log", "--oneline", "{}..HEAD".format(ref))
|
|
527
|
+
diff = git("diff", "{}...HEAD".format(ref))
|
|
528
|
+
return "\n".join([
|
|
529
|
+
"# ctxpack fork-diff -- planted delta vs {} -- {}".format(ref, source_label),
|
|
530
|
+
"", "## git log ({}..HEAD)".format(ref), log or "(no new commits)",
|
|
531
|
+
"", "## git diff ({}...HEAD)".format(ref), diff or "(no diff)", "",
|
|
532
|
+
])
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
# ----------------------------------- core -----------------------------------
|
|
536
|
+
def pack(source, out_dir, budget_tokens=DEFAULT_BUDGET_TOKENS,
|
|
537
|
+
bytes_per_token=BYTES_PER_TOKEN, stage_retrieve=False, stage_dir=None,
|
|
538
|
+
do_ctags=False, fork_diff=None, emit_context_file=None, to_stdout=False,
|
|
539
|
+
debug=False, zip_password=None):
|
|
540
|
+
"""Run the full FILTER->GATE->ROUTE->MANIFEST pipeline. Writes bundle.txt,
|
|
541
|
+
manifest.json and manifest.md into out_dir. Returns the manifest dict."""
|
|
542
|
+
source = os.path.abspath(source)
|
|
543
|
+
tmp_root = None
|
|
544
|
+
try:
|
|
545
|
+
if os.path.isdir(source):
|
|
546
|
+
root, source_type = source, "dir"
|
|
547
|
+
elif os.path.isfile(source):
|
|
548
|
+
tmp_root = tempfile.mkdtemp(prefix="ctxpack_x_")
|
|
549
|
+
source_type = extract_archive(source, tmp_root, zip_password=zip_password)
|
|
550
|
+
root = tmp_root
|
|
551
|
+
_dbg(debug, "extracted {} archive -> {}".format(source_type, root))
|
|
552
|
+
else:
|
|
553
|
+
raise ValueError("source not found: {}".format(source))
|
|
554
|
+
|
|
555
|
+
source_label = os.path.basename(source.rstrip("/\\")) or source
|
|
556
|
+
scanned = scan(root, bytes_per_token, debug=debug)
|
|
557
|
+
included, excluded = scanned["included"], scanned["excluded"]
|
|
558
|
+
|
|
559
|
+
inc_bytes = sum(r["bytes"] for r in included)
|
|
560
|
+
kept_tokens = sum(r["est_tokens"] for r in included)
|
|
561
|
+
exc_bytes = sum(r["bytes"] for r in excluded)
|
|
562
|
+
fits = kept_tokens <= budget_tokens
|
|
563
|
+
mode = "paste" if fits else "retrieve"
|
|
564
|
+
|
|
565
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
566
|
+
extras = {}
|
|
567
|
+
staged_at = None
|
|
568
|
+
|
|
569
|
+
# --fork-diff overrides the bundle body (both modes) when usable.
|
|
570
|
+
fork_body = _fork_diff_bundle(root, fork_diff, source_label) if fork_diff else None
|
|
571
|
+
|
|
572
|
+
if fork_body is not None:
|
|
573
|
+
bundle = fork_body
|
|
574
|
+
extras["fork_diff"] = "emitted"
|
|
575
|
+
elif mode == "paste":
|
|
576
|
+
bundle = render_paste(root, included, kept_tokens, budget_tokens, source_label)
|
|
577
|
+
else:
|
|
578
|
+
if stage_retrieve or stage_dir or do_ctags:
|
|
579
|
+
target_stage = stage_dir or os.path.join(out_dir, "keep_files")
|
|
580
|
+
staged_at = _stage_files(root, included, target_stage,
|
|
581
|
+
clean=(stage_dir is None))
|
|
582
|
+
bundle = render_retrieve(included, kept_tokens, budget_tokens,
|
|
583
|
+
source_label, staged_dir=staged_at)
|
|
584
|
+
if do_ctags:
|
|
585
|
+
extras.update(_run_ctags(staged_at, out_dir))
|
|
586
|
+
|
|
587
|
+
manifest = {
|
|
588
|
+
"tool": "ctxpack",
|
|
589
|
+
"version": "1.0",
|
|
590
|
+
"source": source,
|
|
591
|
+
"source_type": source_type,
|
|
592
|
+
"budget_tokens": budget_tokens,
|
|
593
|
+
"bytes_per_token": bytes_per_token,
|
|
594
|
+
"mode": mode,
|
|
595
|
+
"fits": fits,
|
|
596
|
+
"totals": {
|
|
597
|
+
"included_files": len(included),
|
|
598
|
+
"included_bytes": inc_bytes,
|
|
599
|
+
"included_est_tokens": kept_tokens,
|
|
600
|
+
"excluded_files": len(excluded),
|
|
601
|
+
"excluded_bytes": exc_bytes,
|
|
602
|
+
"scanned_files": len(included) + len(excluded),
|
|
603
|
+
},
|
|
604
|
+
"included": included,
|
|
605
|
+
"excluded": excluded,
|
|
606
|
+
"signals": scanned["signals"],
|
|
607
|
+
"outputs": dict({"bundle": "bundle.txt",
|
|
608
|
+
"manifest_json": "manifest.json",
|
|
609
|
+
"manifest_md": "manifest.md"}, **extras),
|
|
610
|
+
}
|
|
611
|
+
if staged_at:
|
|
612
|
+
manifest["outputs"]["staged_dir"] = staged_at
|
|
613
|
+
|
|
614
|
+
bundle_path = os.path.join(out_dir, "bundle.txt")
|
|
615
|
+
_write(bundle_path, bundle)
|
|
616
|
+
_write(os.path.join(out_dir, "manifest.json"),
|
|
617
|
+
json.dumps(manifest, indent=2, sort_keys=False) + "\n")
|
|
618
|
+
_write(os.path.join(out_dir, "manifest.md"), render_manifest_md(manifest))
|
|
619
|
+
if emit_context_file:
|
|
620
|
+
os.makedirs(os.path.dirname(os.path.abspath(emit_context_file)) or ".",
|
|
621
|
+
exist_ok=True)
|
|
622
|
+
_write(emit_context_file, bundle)
|
|
623
|
+
if to_stdout:
|
|
624
|
+
sys.stdout.write(bundle)
|
|
625
|
+
if not bundle.endswith("\n"):
|
|
626
|
+
sys.stdout.write("\n")
|
|
627
|
+
return manifest
|
|
628
|
+
finally:
|
|
629
|
+
if tmp_root:
|
|
630
|
+
shutil.rmtree(tmp_root, ignore_errors=True)
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _write(path, text):
|
|
634
|
+
with open(path, "w", encoding="utf-8", newline="\n") as fh:
|
|
635
|
+
fh.write(text)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
# ----------------------------------- CLI ------------------------------------
|
|
639
|
+
def build_parser():
|
|
640
|
+
p = argparse.ArgumentParser(
|
|
641
|
+
prog="ctxpack",
|
|
642
|
+
description="Pack a challenge's source into an inject-ready graybox bundle.")
|
|
643
|
+
p.add_argument("source", help="path to a source directory or archive (.zip/.tar[.gz])")
|
|
644
|
+
p.add_argument("-o", "--out", default="./ctxpack-out",
|
|
645
|
+
help="output directory (default: ./ctxpack-out)")
|
|
646
|
+
p.add_argument("--budget", type=int, default=DEFAULT_BUDGET_TOKENS,
|
|
647
|
+
help="token budget for paste vs retrieve (default: %(default)s)")
|
|
648
|
+
p.add_argument("--bytes-per-token", type=float, default=BYTES_PER_TOKEN,
|
|
649
|
+
help="token estimate divisor (default: %(default)s)")
|
|
650
|
+
p.add_argument("--stdout", action="store_true", help="also print the bundle to stdout")
|
|
651
|
+
p.add_argument("--emit-context-file", metavar="PATH",
|
|
652
|
+
help="also write the bundle to this exact path (for direct wiring)")
|
|
653
|
+
p.add_argument("--stage-retrieve", action="store_true",
|
|
654
|
+
help="in retrieve mode, also copy KEEP files to a staging dir")
|
|
655
|
+
p.add_argument("--stage-dir", metavar="DIR",
|
|
656
|
+
help="override staging location (default: OUT/keep_files)")
|
|
657
|
+
p.add_argument("--ctags", action="store_true",
|
|
658
|
+
help="[stretch] run ctags -R over KEEP files in retrieve mode")
|
|
659
|
+
p.add_argument("--fork-diff", nargs="?", const=True, metavar="UPSTREAM_REF",
|
|
660
|
+
help="[stretch] emit git log/diff vs upstream instead of the bundle")
|
|
661
|
+
p.add_argument("--zip-password", metavar="PW",
|
|
662
|
+
help="password for an encrypted zip handout (classic ZipCrypto)")
|
|
663
|
+
p.add_argument("--debug", action="store_true",
|
|
664
|
+
help="trace every KEEP/DROP decision to stderr")
|
|
665
|
+
return p
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def main(argv=None):
|
|
669
|
+
args = build_parser().parse_args(argv)
|
|
670
|
+
try:
|
|
671
|
+
m = pack(
|
|
672
|
+
args.source, args.out, budget_tokens=args.budget,
|
|
673
|
+
bytes_per_token=args.bytes_per_token, stage_retrieve=args.stage_retrieve,
|
|
674
|
+
stage_dir=args.stage_dir, do_ctags=args.ctags, fork_diff=args.fork_diff,
|
|
675
|
+
emit_context_file=args.emit_context_file, to_stdout=args.stdout,
|
|
676
|
+
debug=args.debug, zip_password=args.zip_password,
|
|
677
|
+
)
|
|
678
|
+
except ValueError as e:
|
|
679
|
+
sys.stderr.write("ctxpack: error: {}\n".format(e))
|
|
680
|
+
return 2
|
|
681
|
+
|
|
682
|
+
t = m["totals"]
|
|
683
|
+
sig = m["signals"]
|
|
684
|
+
flags = [k for k in ("git_present", "binaries_only") if sig[k]]
|
|
685
|
+
if sig.get("stack"):
|
|
686
|
+
flags.append("stack={}".format(sig["stack"]))
|
|
687
|
+
if sig["known_ctf_markers"]:
|
|
688
|
+
flags.append("ctf_markers={}".format(len(sig["known_ctf_markers"])))
|
|
689
|
+
sys.stderr.write(
|
|
690
|
+
"ctxpack: mode={mode} kept={files} files ~{tok} tokens "
|
|
691
|
+
"(budget {bud}) excluded={exc}{sig}\n".format(
|
|
692
|
+
mode=m["mode"], files=t["included_files"], tok=t["included_est_tokens"],
|
|
693
|
+
bud=m["budget_tokens"], exc=t["excluded_files"],
|
|
694
|
+
sig=(" signals[{}]".format(",".join(flags)) if flags else "")))
|
|
695
|
+
sys.stderr.write("ctxpack: wrote {0}/bundle.txt , {0}/manifest.json , "
|
|
696
|
+
"{0}/manifest.md\n".format(os.path.abspath(args.out)))
|
|
697
|
+
return 0
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
if __name__ == "__main__":
|
|
701
|
+
sys.exit(main())
|