mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-16 15:01:22 +03:00
19c5113511
* fix(attestation): include runtime libs in release sbom * ci: verify staged skill release import closure * fix(release): include missing skill runtime sbom files * fix(release): require files for import closure --------- Co-authored-by: David Abutbul <David.a@prompt.security>
101 lines
3.4 KiB
Python
Executable File
101 lines
3.4 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Verify staged skill release JS/TS relative 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. 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}/[^'\"]+)['\"]",
|
|
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"]
|
|
|
|
|
|
def candidate_paths(importer: Path, spec: str) -> list[Path]:
|
|
base = (importer.parent / spec).resolve()
|
|
candidates = [base]
|
|
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")
|
|
for match in IMPORT_RE.finditer(text):
|
|
spec = match.group("spec")
|
|
candidates = candidate_paths(source, spec)
|
|
if any(is_resolved_file(candidate, root) for candidate in candidates):
|
|
continue
|
|
|
|
rel_source = source.relative_to(root).as_posix()
|
|
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())
|