skillhub/scripts/tests/scanner-2-1-contract-test.sh
dongmucat 9f92debf00 fix(scanner): redact mounted route findings
Signed-off-by: dongmucat <1127093059@qq.com>
2026-09-24 15:01:28 +08:00

384 lines
13 KiB
Bash
Executable file

#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
TMP_DIR="$(mktemp -d)"
CONTAINERS=()
cleanup() {
local status=$?
trap - EXIT
if ((${#CONTAINERS[@]})); then
docker rm -f "${CONTAINERS[@]}" >/dev/null 2>&1 || true
fi
rm -rf "$TMP_DIR"
exit "$status"
}
trap cleanup EXIT
if [[ -n "${SCANNER_IMAGE:-}" ]]; then
IMAGE="$SCANNER_IMAGE"
else
IMAGE="skillhub-scanner-contract-test:$(date +%s)"
docker build -t "$IMAGE" "$REPO_ROOT/scanner"
fi
python3 - "$TMP_DIR" <<'PY'
import json
import sys
import zipfile
from pathlib import Path
root = Path(sys.argv[1])
github_canary = "ghp_" + "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8"
openai_canary = "sk-proj-" + "Z9y8X7w6V5u4T3s2R1q0" * 3
def write_zip(name, files):
with zipfile.ZipFile(root / name, "w", compression=zipfile.ZIP_STORED) as archive:
for path, content in files.items():
archive.writestr(path, content)
manifest = """---
name: contract-skill
description: Scanner 2.1 runtime contract fixture.
---
Harmless contract fixture.
"""
write_zip("safe.zip", {"contract-skill/SKILL.md": manifest})
write_zip(
"javascript-secret.zip",
{
"contract-skill/SKILL.md": manifest,
"contract-skill/index.js": f'const githubToken = "{github_canary}";\n',
},
)
write_zip(
"dotenv-secret.zip",
{
"contract-skill/SKILL.md": manifest,
"contract-skill/.env": f"OPENAI_API_KEY={openai_canary}\n",
},
)
large_files = {"large-skill/SKILL.md": manifest.replace("contract-skill", "large-skill")}
for index in range(5):
large_files[f"large-skill/payload-{index}.txt"] = b"x" * (10 * 1024 * 1024)
large_files["large-skill/payload-5.txt"] = b"x" * (1024 * 1024)
write_zip("large-51mib.zip", large_files)
(root / "canaries.json").write_text(
json.dumps({"github": github_canary, "openai": openai_canary}), encoding="utf-8"
)
local_skill = root / "local-scan" / "contract-skill"
local_skill.mkdir(parents=True)
(local_skill / "SKILL.md").write_text(manifest, encoding="utf-8")
PY
start_scanner() {
local name=$1
shift
docker run -d --name "$name" -p 127.0.0.1::8000 "$@" "$IMAGE" >/dev/null
CONTAINERS+=("$name")
SCANNER_PORT="$(docker port "$name" 8000/tcp | awk -F: 'END {print $NF}')"
}
wait_for_health() {
local url=$1
python3 - "$url" <<'PY'
import json
import sys
import time
import urllib.error
import urllib.request
url = sys.argv[1]
last_error = None
for _ in range(120):
try:
with urllib.request.urlopen(url + "/health", timeout=2) as response:
payload = json.load(response)
if response.status == 200:
break
except (OSError, urllib.error.URLError, json.JSONDecodeError) as error:
last_error = error
time.sleep(1)
else:
raise SystemExit(f"scanner did not become healthy: {last_error}")
if payload.get("version") != "2.1.0":
raise SystemExit(f"expected scanner 2.1.0, got {payload!r}")
expected = {"static_analyzer", "bytecode_analyzer", "pipeline_analyzer"}
available = set(payload.get("analyzers_available", []))
if not expected.issubset(available):
raise SystemExit(f"missing deterministic analyzers: {sorted(expected - available)}")
PY
}
post_zip() {
local url=$1
local archive=$2
local output=$3
python3 - "$url" "$archive" "$output" <<'PY'
import json
import sys
import urllib.error
import urllib.request
import uuid
from pathlib import Path
url, archive_path, output_path = sys.argv[1:]
boundary = "----skillhub-" + uuid.uuid4().hex
archive = Path(archive_path).read_bytes()
prefix = (
f"--{boundary}\r\n"
'Content-Disposition: form-data; name="policy"\r\n\r\n'
"balanced\r\n"
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="file"; filename="{Path(archive_path).name}"\r\n'
"Content-Type: application/zip\r\n\r\n"
).encode()
body = prefix + archive + f"\r\n--{boundary}--\r\n".encode()
request = urllib.request.Request(
url + "/scan-upload",
data=body,
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=900) as response:
raw = response.read()
status = response.status
except urllib.error.HTTPError as error:
raw = error.read()
status = error.code
Path(output_path).write_bytes(raw)
if status != 200:
raise SystemExit(f"scan upload returned HTTP {status}: {raw[:1000]!r}")
try:
payload = json.loads(raw)
except json.JSONDecodeError as error:
raise SystemExit(f"scan upload did not return JSON: {error}") from error
required = {"scan_id", "skill_name", "findings", "scan_metadata"}
if not required.issubset(payload) or not isinstance(payload["findings"], list):
raise SystemExit(f"invalid scan response contract: {payload!r}")
PY
}
assert_hardcoded_secret() {
local response=$1
python3 - "$response" <<'PY'
import json
import sys
payload = json.load(open(sys.argv[1], encoding="utf-8"))
if not any(finding.get("category") == "hardcoded_secrets" for finding in payload["findings"]):
raise SystemExit("expected a hardcoded_secrets finding")
PY
}
MAIN_CONTAINER="skillhub-scanner-contract-main-$$"
start_scanner "$MAIN_CONTAINER"
MAIN_PORT="$SCANNER_PORT"
MAIN_URL="http://127.0.0.1:$MAIN_PORT"
wait_for_health "$MAIN_URL"
docker exec -i "$MAIN_CONTAINER" python - <<'PY'
from types import SimpleNamespace
from fastapi import APIRouter, FastAPI, Response
from fastapi.testclient import TestClient
from pydantic import BaseModel
from skill_scanner.core.analyzers.llm_analyzer import LLMProvider
import skillhub_scanner_app as scanner_app
from skillhub_scanner_app import _redact_supported_tokens
canaries = {
"aws": "AKIA1234567890ABCDEF",
"github": "github_pat_abcdefghijklmnopqrstuvwxyz",
"jwt": "eyJabcde.abcdefgh.ijklmnop",
"labeled": "custom-secret-1234567890",
"private_key": "-----BEGIN PRIVATE KEY-----\nabc123\n-----END PRIVATE KEY-----",
}
findings = {
"aws": canaries["aws"],
"github": canaries["github"],
"authorization": f"Bearer {canaries['jwt']}",
"labeled": f"api_key={canaries['labeled']}",
"private_key": canaries["private_key"],
}
redacted = str(_redact_supported_tokens(findings))
for label, canary in canaries.items():
if canary in redacted:
raise SystemExit(f"{label} canary was not redacted")
safe_values = ["line one\n\tline two", "x" * 5000]
if _redact_supported_tokens(safe_values) != safe_values:
raise SystemExit("redaction changed safe multiline or long finding text")
mixed = "before\napi_key=custom-secret-1234567890\tafter"
if _redact_supported_tokens(mixed) != "before\napi_key=<redacted>\tafter":
raise SystemExit("redaction changed non-secret characters around a credential")
if not LLMProvider.is_valid_provider("azure-openai") or LLMProvider.is_valid_provider("azure"):
raise SystemExit("unexpected Scanner 2.1 Azure provider contract")
class ScanResponse(BaseModel):
findings: list[dict]
safe_text: str
upstream_router = APIRouter()
def install_http_canary(path):
@upstream_router.post(path, response_model=ScanResponse)
async def scan(response: Response):
response.headers["X-Contract"] = "preserved"
return ScanResponse(
findings=[{"description": f"api_key={canaries['labeled']}"}],
safe_text="line one\n" + ("x" * 5000),
)
install_http_canary("/scan")
install_http_canary("/scan-upload")
http_app = FastAPI()
http_app.include_router(upstream_router)
nested_app = FastAPI()
nested_app.include_router(upstream_router)
http_app.mount("/nested", nested_app)
previous_app = scanner_app.app
previous_router = scanner_app._upstream_router
scanner_app.app = http_app
scanner_app._upstream_router = SimpleNamespace(router=upstream_router)
try:
scanner_app._install_scan_response_redaction()
with TestClient(http_app) as client:
for path in ("/scan", "/scan-upload", "/nested/scan", "/nested/scan-upload"):
response = client.post(path)
if response.status_code != 200:
raise SystemExit(f"HTTP canary failed for {path}: {response.status_code}")
if response.headers.get("X-Contract") != "preserved":
raise SystemExit(f"HTTP canary changed headers for {path}")
body = response.text
if canaries["labeled"] in body:
raise SystemExit(f"HTTP canary leaked secret for {path}")
if "line one\\n" not in body or ("x" * 5000) not in body:
raise SystemExit(f"HTTP canary changed safe text for {path}")
finally:
scanner_app.app = previous_app
scanner_app._upstream_router = previous_router
PY
docker exec "$MAIN_CONTAINER" python -c \
'from pathlib import Path; Path("/tmp/skillhub-scanner-runtime/stale-after-timeout").write_text("stale")'
docker restart "$MAIN_CONTAINER" >/dev/null
MAIN_PORT="$(docker port "$MAIN_CONTAINER" 8000/tcp | awk -F: 'END {print $NF}')"
MAIN_URL="http://127.0.0.1:$MAIN_PORT"
wait_for_health "$MAIN_URL"
docker exec "$MAIN_CONTAINER" python -c \
'from pathlib import Path; assert not Path("/tmp/skillhub-scanner-runtime/stale-after-timeout").exists()'
post_zip "$MAIN_URL" "$TMP_DIR/safe.zip" "$TMP_DIR/safe.json"
post_zip "$MAIN_URL" "$TMP_DIR/javascript-secret.zip" "$TMP_DIR/javascript-secret.json"
post_zip "$MAIN_URL" "$TMP_DIR/dotenv-secret.zip" "$TMP_DIR/dotenv-secret.json"
post_zip "$MAIN_URL" "$TMP_DIR/large-51mib.zip" "$TMP_DIR/large-51mib.json"
assert_hardcoded_secret "$TMP_DIR/javascript-secret.json"
assert_hardcoded_secret "$TMP_DIR/dotenv-secret.json"
python3 - "$TMP_DIR/safe.json" <<'PY'
import json
import sys
payload = json.load(open(sys.argv[1], encoding="utf-8"))
cel = payload.get("scan_metadata", {}).get("cel", {})
if cel.get("runtime") != "cel-go":
raise SystemExit(f"expected CEL runtime cel-go, got {cel!r}")
if not cel.get("runtime_version"):
raise SystemExit(f"expected a non-empty CEL runtime version, got {cel!r}")
if cel.get("fallbacks") != 0 or cel.get("errors") != []:
raise SystemExit(f"unexpected CEL fallback/error telemetry: {cel!r}")
PY
docker logs "$MAIN_CONTAINER" >"$TMP_DIR/main-container.log" 2>&1
python3 - "$TMP_DIR/canaries.json" "$TMP_DIR" <<'PY'
import json
import sys
from pathlib import Path
canaries = json.load(open(sys.argv[1], encoding="utf-8"))
root = Path(sys.argv[2])
for path in [*root.glob("*.json"), root / "main-container.log"]:
if path.name == "canaries.json":
continue
content = path.read_text(encoding="utf-8", errors="replace")
for label, canary in canaries.items():
if canary in content:
def find_canary(value, location="$"):
if isinstance(value, dict):
for key, child in value.items():
found = find_canary(child, f"{location}.{key}")
if found:
return found
elif isinstance(value, list):
for index, child in enumerate(value):
found = find_canary(child, f"{location}[{index}]")
if found:
return found
elif isinstance(value, str) and canary in value:
return location
return None
location = None
if path.suffix == ".json":
location = find_canary(json.loads(content))
raise SystemExit(
f"full {label} canary leaked through {path.name}"
+ (f" at {location}" if location else "")
)
PY
LOCAL_CONTAINER="skillhub-scanner-contract-local-$$"
start_scanner "$LOCAL_CONTAINER" \
-e SKILL_SCANNER_ALLOWED_ROOTS=/tmp/skillhub-scans \
-v "$TMP_DIR/local-scan:/tmp/skillhub-scans:ro"
LOCAL_PORT="$SCANNER_PORT"
LOCAL_URL="http://127.0.0.1:$LOCAL_PORT"
wait_for_health "$LOCAL_URL"
python3 - "$LOCAL_URL" <<'PY'
import json
import sys
import urllib.error
import urllib.request
url = sys.argv[1]
def scan(path):
request = urllib.request.Request(
url + "/scan",
data=json.dumps({"skill_directory": path, "policy": "balanced"}).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=900) as response:
return response.status, json.load(response)
except urllib.error.HTTPError as error:
return error.code, json.loads(error.read())
inside_status, inside = scan("/tmp/skillhub-scans/contract-skill")
if inside_status != 200 or not isinstance(inside, dict):
raise SystemExit(f"allowed local scan failed: HTTP {inside_status}: {inside!r}")
outside_status, outside = scan("/etc")
if outside_status not in (403, 404) or not isinstance(outside, dict):
raise SystemExit(f"outside-root scan should be denied, got HTTP {outside_status}: {outside!r}")
PY
echo "scanner-2-1-contract-test passed"