mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-13 05:28:02 +03:00
4dbac421ab
* feat(advisories): add provisional ghsa feed * fix(workflows): include advisory signatures in checksums * fix(workflows): mirror ghsa feed at release root * feat(advisories): consolidate ghsa into agent feed * ci(advisories): consolidate ghsa during nvd poll * fix(advisories): retain unreplaced ghsa feed entries * chore(skills): bump advisory feed consumers * fix(release): resolve ts import closure dry run * fix(release): preserve urls while stripping comments * fix(release): ignore skill test-only changes * fix(advisories): follow ghsa pagination links * test(advisories): add nvd ghsa pipeline dry run
176 lines
5.6 KiB
Python
Executable File
176 lines
5.6 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Verify staged skill release JS/TS imports are self-contained.
|
|
|
|
The skill release workflow builds archives from `skill.json.sbom.files`. If a
|
|
runtime helper exists in the repo but is omitted from the SBOM, the staged
|
|
release can contain files whose relative imports point at missing files or
|
|
remote runtime imports. This script checks the staged payload, not the source
|
|
tree, so it catches exactly what would ship.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
IMPORT_RE = re.compile(
|
|
r"(?:"
|
|
r"\bimport\s+(?:type\s+)?(?:[^'\";]+?\s+from\s+)?"
|
|
r"|\bexport\s+(?:type\s+)?[^'\";]+?\s+from\s+"
|
|
r"|\bimport\s*\(\s*"
|
|
r"|\brequire\s*\(\s*"
|
|
r")"
|
|
r"['\"](?P<spec>(?:\.{1,2}/|https?://)[^'\"]+)['\"]",
|
|
re.MULTILINE,
|
|
)
|
|
|
|
SOURCE_SUFFIXES = {".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"}
|
|
RESOLUTION_SUFFIXES = ["", ".mjs", ".js", ".cjs", ".mts", ".ts", ".cts", ".json"]
|
|
INDEX_FILENAMES = ["index.mjs", "index.js", "index.cjs", "index.mts", "index.ts", "index.cts", "index.json"]
|
|
TS_IMPORTER_SUFFIXES = {".ts", ".mts", ".cts"}
|
|
JS_TO_TS_SUFFIX = {".js": ".ts", ".mjs": ".mts", ".cjs": ".cts"}
|
|
|
|
|
|
def strip_js_ts_comments(text: str) -> str:
|
|
stripped: list[str] = []
|
|
state = "code"
|
|
i = 0
|
|
|
|
while i < len(text):
|
|
char = text[i]
|
|
next_char = text[i + 1] if i + 1 < len(text) else ""
|
|
|
|
if state == "line_comment":
|
|
if char in "\r\n":
|
|
stripped.append(char)
|
|
state = "code"
|
|
i += 1
|
|
continue
|
|
|
|
if state == "block_comment":
|
|
if char == "*" and next_char == "/":
|
|
state = "code"
|
|
i += 2
|
|
continue
|
|
if char in "\r\n":
|
|
stripped.append(char)
|
|
i += 1
|
|
continue
|
|
|
|
if state in {"single", "double", "template"}:
|
|
stripped.append(char)
|
|
if char == "\\" and i + 1 < len(text):
|
|
stripped.append(text[i + 1])
|
|
i += 2
|
|
continue
|
|
if (state == "single" and char == "'") or (state == "double" and char == '"') or (
|
|
state == "template" and char == "`"
|
|
):
|
|
state = "code"
|
|
i += 1
|
|
continue
|
|
|
|
if char == "/" and next_char == "/":
|
|
stripped.append(" ")
|
|
state = "line_comment"
|
|
i += 2
|
|
continue
|
|
if char == "/" and next_char == "*":
|
|
stripped.append(" ")
|
|
state = "block_comment"
|
|
i += 2
|
|
continue
|
|
|
|
stripped.append(char)
|
|
if char == "'":
|
|
state = "single"
|
|
elif char == '"':
|
|
state = "double"
|
|
elif char == "`":
|
|
state = "template"
|
|
i += 1
|
|
|
|
return "".join(stripped)
|
|
|
|
|
|
def is_remote_spec(spec: str) -> bool:
|
|
return spec.startswith(("http://", "https://"))
|
|
|
|
|
|
def candidate_paths(importer: Path, spec: str) -> list[Path]:
|
|
base = (importer.parent / spec).resolve()
|
|
candidates = [base]
|
|
if importer.suffix in TS_IMPORTER_SUFFIXES and base.suffix in JS_TO_TS_SUFFIX:
|
|
candidates.append(base.with_suffix(JS_TO_TS_SUFFIX[base.suffix]))
|
|
candidates.extend(base.with_suffix(suffix) for suffix in RESOLUTION_SUFFIXES if suffix and base.suffix == "")
|
|
candidates.extend(base / name for name in INDEX_FILENAMES)
|
|
return candidates
|
|
|
|
|
|
def is_within(path: Path, root: Path) -> bool:
|
|
try:
|
|
path.resolve().relative_to(root)
|
|
return True
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def is_resolved_file(candidate: Path, root: Path) -> bool:
|
|
return candidate.is_file() and is_within(candidate, root)
|
|
|
|
|
|
def verify_import_closure(root: Path) -> list[str]:
|
|
root = root.resolve()
|
|
failures: list[str] = []
|
|
|
|
for source in sorted(p for p in root.rglob("*") if p.is_file() and p.suffix in SOURCE_SUFFIXES):
|
|
text = source.read_text(encoding="utf-8", errors="ignore")
|
|
text = strip_js_ts_comments(text)
|
|
for match in IMPORT_RE.finditer(text):
|
|
spec = match.group("spec")
|
|
rel_source = source.relative_to(root).as_posix()
|
|
if is_remote_spec(spec):
|
|
failures.append(f"{rel_source} imports remote runtime import {spec}")
|
|
continue
|
|
|
|
candidates = candidate_paths(source, spec)
|
|
if any(is_resolved_file(candidate, root) for candidate in candidates):
|
|
continue
|
|
|
|
display_target = (source.parent / spec).resolve()
|
|
try:
|
|
rel_target = display_target.relative_to(root).as_posix()
|
|
except ValueError:
|
|
rel_target = str(display_target)
|
|
failures.append(f"{rel_source} imports {spec} but {rel_target} is absent from staged release")
|
|
|
|
return failures
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("staged_skill_dir", type=Path, help="Staged skill payload directory, e.g. $INNER_DIR")
|
|
args = parser.parse_args()
|
|
|
|
root = args.staged_skill_dir
|
|
if not root.is_dir():
|
|
print(f"error: staged skill directory not found: {root}", file=sys.stderr)
|
|
return 2
|
|
|
|
failures = verify_import_closure(root)
|
|
if failures:
|
|
print("Release import-closure check failed:", file=sys.stderr)
|
|
for failure in failures:
|
|
print(f" - {failure}", file=sys.stderr)
|
|
print("Add the missing runtime file(s) to skill.json sbom.files or remove the stale import.", file=sys.stderr)
|
|
return 1
|
|
|
|
print(f"Release import-closure check OK: {root}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|