Merge pull request #879 from iflytek/feature/scanner-2-1-upgrade

fix(scanner): harden Scanner 2.1 integration
This commit is contained in:
dongmucat 2026-09-24 15:47:20 +08:00 • committed by GitHub
commit 0fd222d1e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 1384 additions and 373 deletions

37
.github/workflows/pr-scanner-image.yml vendored Normal file
View file

@ -0,0 +1,37 @@
name: PR Scanner Image
on:
pull_request:
paths:
- 'scanner/**'
- 'scripts/tests/scanner-2-1-contract-test.sh'
- '.github/workflows/pr-scanner-image.yml'
permissions:
contents: read
jobs:
scanner-contract:
name: Scanner contract (${{ matrix.arch }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
arch: [amd64, arm64]
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- name: Build scanner image
uses: docker/build-push-action@v6
with:
context: scanner
platforms: linux/${{ matrix.arch }}
tags: skillhub-scanner-contract:${{ matrix.arch }}-${{ github.sha }}
load: true
- name: Run scanner contract
env:
SCANNER_IMAGE: skillhub-scanner-contract:${{ matrix.arch }}-${{ github.sha }}
run: bash scripts/tests/scanner-2-1-contract-test.sh

View file

@ -4,9 +4,16 @@ on:
pull_request:
paths:
- 'scripts/**'
- 'scanner/**'
- 'docker-compose.yml'
- '.env.release.example'
- '.env.release.draft'
- 'compose.release.yml'
- 'deploy/k8s/base/configmap.yaml'
- 'deploy/k8s/base/scanner-deployment.yaml'
- 'charts/skillhub/values.yaml'
- 'charts/skillhub/values.schema.json'
- 'charts/skillhub/templates/scanner-deployment.yaml'
- 'server/Dockerfile'
- 'Makefile'
- 'web/Dockerfile'
@ -36,6 +43,10 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '21'
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- run: python -m unittest discover -s scanner/tests -p 'test_*.py'
- run: bash scripts/tests/publish-cli-test.sh
- run: bash scripts/tests/runtime-secret-test.sh
- run: bash scripts/tests/validate-release-config-test.sh

View file

@ -56,6 +56,8 @@ spec:
{{- with .Values.scanner.extraEnv }}
{{- toYaml . | nindent 12 }}
{{- end }}
- name: SKILLHUB_SCANNER_MAX_UPLOAD_SIZE_BYTES
value: {{ .Values.scanner.maxUploadSizeBytes | quote }}
resources:
{{- toYaml .Values.scanner.resources | nindent 12 }}
readinessProbe:

View file

@ -496,10 +496,11 @@
{
"type": "object",
"additionalProperties": false,
"required": ["enabled", "replicaCount", "image", "service", "resources", "extraEnv", "podAnnotations", "imagePullSecrets", "nodeSelector", "tolerations", "affinity", "probes", "autoscaling", "podDisruptionBudget"],
"required": ["enabled", "replicaCount", "maxUploadSizeBytes", "image", "service", "resources", "extraEnv", "podAnnotations", "imagePullSecrets", "nodeSelector", "tolerations", "affinity", "probes", "autoscaling", "podDisruptionBudget"],
"properties": {
"enabled": { "type": "boolean" },
"replicaCount": { "type": "integer", "minimum": 1 },
"maxUploadSizeBytes": { "type": "integer", "minimum": 110100480 },
"image": { "$ref": "#/definitions/image" },
"service": {
"type": "object",

View file

@ -450,6 +450,7 @@ web:
scanner:
enabled: true
replicaCount: 1
maxUploadSizeBytes: 110100480
image:
registry: ""
tag: ""

View file

@ -8,8 +8,9 @@ services:
SKILL_SCANNER_LLM_MODEL: ${SKILL_SCANNER_LLM_MODEL:-}
SKILLHUB_SCANNER_MAX_CONCURRENT_SCANS: ${SKILLHUB_SCANNER_MAX_CONCURRENT_SCANS:-1}
SKILLHUB_SCANNER_HARD_TIMEOUT_SECONDS: ${SKILLHUB_SCANNER_HARD_TIMEOUT_SECONDS:-930}
SKILLHUB_SCANNER_MAX_UPLOAD_SIZE_BYTES: ${SKILLHUB_SCANNER_MAX_UPLOAD_SIZE_BYTES:-110100480}
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8000/health"]
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2).read()"]
interval: 10s
timeout: 5s
retries: 10

View file

@ -39,6 +39,7 @@ data:
skill-scan-reclaim-min-idle: PT16M
skill-scanner-max-concurrent-scans: "1"
skill-scanner-hard-timeout-seconds: "930"
skill-scanner-max-upload-size-bytes: "110100480"
# Bootstrap 管理员配置(非敏感)
bootstrap-admin-enabled: "true"

View file

@ -50,6 +50,11 @@ spec:
configMapKeyRef:
name: skillhub-config
key: skill-scanner-hard-timeout-seconds
- name: SKILLHUB_SCANNER_MAX_UPLOAD_SIZE_BYTES
valueFrom:
configMapKeyRef:
name: skillhub-config
key: skill-scanner-max-upload-size-bytes
readinessProbe:
httpGet:
path: /health

View file

@ -7,8 +7,9 @@ services:
SKILL_SCANNER_LLM_API_KEY: ${SKILL_SCANNER_LLM_API_KEY:-}
SKILL_SCANNER_LLM_BASE_URL: ${SKILL_SCANNER_LLM_BASE_URL:-}
SKILL_SCANNER_LLM_MODEL: ${SKILL_SCANNER_LLM_MODEL:-}
SKILLHUB_SCANNER_MAX_UPLOAD_SIZE_BYTES: ${SKILLHUB_SCANNER_MAX_UPLOAD_SIZE_BYTES:-110100480}
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8000/health"]
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2).read()"]
interval: 10s
timeout: 5s
retries: 5

View file

@ -1,8 +1,14 @@
# Skill Scanner Backend Runtime Guide
> **Document status:** This is a historical backend implementation note, updated with the
> current Scanner 2.1.0 runtime and rollout constraints. It does not expand the supported
> analyzer or deployment contract.
## Overview
SkillHub now supports a backend-only security scanning chain around `skill-scanner`.
The Scanner image pins `cisco-ai-skill-scanner==2.1.0` and uses a glibc-based Linux runtime.
Published images support `linux/amd64` and `linux/arm64`.
The publish flow changes are:
1. publish request enters `SkillPublishService`
@ -20,14 +26,17 @@ Frontend is intentionally out of scope here. The frontend should fetch audit det
Two runtime modes are supported:
- `local`
Use `POST /scan` and pass a filesystem path. This only works when SkillHub and `skill-scanner` can see the same files.
Use `POST /scan` and pass a filesystem path. SkillHub and `skill-scanner` must mount the same
directory at the same path. The Scanner must also allow that root; for the standard path, set
`SKILL_SCANNER_ALLOWED_ROOTS=/tmp/skillhub-scans`.
- `upload`
Use `POST /scan-upload` and upload the package archive. This is the safer default for split deployments.
Use `POST /scan-upload` and upload the package archive. This is the mode used by the official
Compose and Kubernetes deployments.
Recommended usage:
- local development with shared filesystem: `local`
- Kubernetes or any split-service deployment: `upload`
- official Compose, Kubernetes, or any split-service deployment: `upload`
## Backend Configuration
@ -70,6 +79,7 @@ Scanner-side optional environment variables:
- `SKILL_SCANNER_LLM_MODEL`
- `SKILLHUB_SCANNER_MAX_CONCURRENT_SCANS` (default `1`)
- `SKILLHUB_SCANNER_HARD_TIMEOUT_SECONDS` (default `930`)
- `SKILLHUB_SCANNER_MAX_UPLOAD_SIZE_BYTES` (default `110100480`, or 105 MiB)
If the LLM variables are absent, the scanner should still run with non-LLM analyzers.
The default timeout ordering is server read timeout (900 seconds), scanner hard timeout
@ -95,6 +105,21 @@ Relevant manifests:
The scanner service is internal-only by default and is consumed by the backend through cluster DNS.
## Rolling Upgrade to Scanner 2.1.0
The Server and Scanner HTTP contracts must be upgraded in this order:
1. deploy the compatibility Server release while the old Scanner is still running
2. drain and remove every old Server instance, including in-flight scan requests
3. upgrade the Scanner to 2.1.0
4. verify `/health` and an upload-mode scan before restoring normal traffic
Do not run an old Server against Scanner 2.1.0. During a mixed-version rollout in either scan mode,
keep AI Defense disabled (`SKILLHUB_SCANNER_USE_AI_DEFENSE=false`, the default). If AI Defense must
remain enabled before the old Scanner is retired, configure its credential directly in the old
Scanner environment using the variable supported by that Scanner version. Never place an AI Defense
key in URL query parameters or request bodies.
## Verification
Verify the scanner service itself:
@ -133,6 +158,10 @@ Response fields include:
- `scannedAt`
- `createdAt`
`isSafe: true` means the scan found no high-risk issue. It does not mean that the scan produced no
findings: lower-severity findings may still be present and `findingsCount` may be non-zero. The UI
therefore renders this state as **No high-risk findings**, not as an unconditional safety guarantee.
## Failure Semantics
- scan task retries are handled by `AbstractStreamConsumer`

View file

@ -205,7 +205,23 @@ A: SkillHub has built-in security scanning. The scanner integration, task orches
## Q: Which version of cisco-ai-skill-scanner does SkillHub use?
A: `scanner/Dockerfile` runs `pip install cisco-ai-skill-scanner` directly without pinning a version, so the latest version on PyPI is pulled when the image is built. To pin a version, do so yourself when customizing the build.
A: `scanner/Dockerfile` pins `cisco-ai-skill-scanner==2.1.0`. The Scanner image uses glibc Linux and supports `linux/amd64` and `linux/arm64`.
## Q: Should the Scanner use upload mode or local mode?
A: The official Compose and Kubernetes deployments use `upload` mode and send skill packages through `POST /scan-upload`. `SKILLHUB_SCANNER_MAX_UPLOAD_SIZE_BYTES` controls the upload limit; its default is `110100480` bytes (105 MiB).
Use `local` mode only when the Server and Scanner can see the same directory at the **same path**. Both services must share the mount, and the Scanner must allow that root; for the standard path, set `SKILL_SCANNER_ALLOWED_ROOTS=/tmp/skillhub-scans`.
## Q: Does “No high-risk findings” mean that a security scan returned no findings?
A: No. A Scanner response with `is_safe=true`, rendered in the UI as “No high-risk findings,” only means that no high-risk issue was found. Lower-severity findings may still exist and `findingsCount` may be greater than zero. Review the finding details instead of treating this state as an unconditional safety guarantee.
## Q: How should I roll out the Scanner 2.1.0 upgrade?
A: First deploy the Server release that is compatible with the 2.1.0 protocol. While the old Scanner is still running, drain and remove every old Server instance and its in-flight scans. Then upgrade the Scanner and verify `/health` plus one upload-mode scan. Do not connect an old Server to Scanner 2.1.0.
During the mixed-version window, keep AI Defense disabled in both upload and local modes (the default is `SKILLHUB_SCANNER_USE_AI_DEFENSE=false`). If AI Defense must remain enabled before the upgrade, configure its credential directly in the old Scanner environment using the variable supported by that Scanner version; never put an AI Defense key in URL query parameters or request bodies.
## Q: How do I troubleshoot a `registry returned 400` error from `skillhub publish` (CLI)?

View file

@ -208,7 +208,23 @@ A: SkillHub 内置安全扫描能力。其中扫描接入、任务编排、审
## Q: SkillHub 使用的 cisco-ai-skill-scanner 是哪个版本?
A: `scanner/Dockerfile` 中直接执行 `pip install cisco-ai-skill-scanner`,未锁定版本,因此构建镜像时会拉取 PyPI 上的最新版本。如需固定版本,可在二次开发时自行锁定。
A: `scanner/Dockerfile` 已固定使用 `cisco-ai-skill-scanner==2.1.0`。Scanner 镜像基于 glibc Linux,支持 `linux/amd64` 和 `linux/arm64`。
## Q: Scanner 应该使用 upload mode 还是 local mode?
A: 官方 Compose 和 Kubernetes 部署使用 `upload` mode,通过 `POST /scan-upload` 上传技能包。上传大小上限由 `SKILLHUB_SCANNER_MAX_UPLOAD_SIZE_BYTES` 控制,默认是 `110100480` 字节(105 MiB)。
`local` mode 仅适用于 Server 与 Scanner 能在**相同路径**看到同一目录的部署。双方需要共享挂载路径,并在 Scanner 中设置允许的根目录;使用标准路径时配置 `SKILL_SCANNER_ALLOWED_ROOTS=/tmp/skillhub-scans`。
## Q: 安全审计显示“未发现高风险问题”,是否代表没有任何 findings?
A: 不是。Scanner 返回 `is_safe=true`、UI 显示“未发现高风险问题”,仅表示没有发现高风险问题;低等级 findings 仍可能存在,`findingsCount` 也可能大于 0。请继续查看 findings 明细,而不要把该状态理解为无条件安全保证。
## Q: 如何滚动升级到 Scanner 2.1.0?
A: 必须先部署兼容 2.1.0 协议的 Server,在旧 Scanner 仍运行时排空并下线所有旧 Server 实例及其进行中的扫描,然后再升级 Scanner,最后验证 `/health` 和一次 upload mode 扫描。不要让旧 Server 连接 Scanner 2.1.0。
混合版本期间,upload 和 local mode 都应保持 AI Defense 关闭(默认 `SKILLHUB_SCANNER_USE_AI_DEFENSE=false`)。如果升级前必须继续使用 AI Defense,应按旧 Scanner 版本支持的环境变量把凭据直接配置到旧 Scanner 环境中;不要把 AI Defense key 放入 URL query 参数或请求体。
## Q: 使用 CLI `skillhub publish` 报错 `registry returned 400` 怎么排查?

View file

@ -1,27 +1,20 @@
FROM python:3.11-alpine
ARG SKILL_SCANNER_VERSION=1.0.2
FROM python:3.11-slim-bookworm
WORKDIR /app
COPY backports/apply_1_0_2_llm_base_url_backport.py /tmp/apply_1_0_2_llm_base_url_backport.py
COPY skillhub_scanner_app.py /app/skillhub_scanner_app.py
RUN pip install --no-cache-dir --only-binary=:all: \
"cisco-ai-skill-scanner==2.1.0" && \
groupadd --system app && \
useradd --system --gid app --home-dir /nonexistent --no-create-home app && \
install -d -o app -g app /tmp/skillhub-scans
RUN pip install --no-cache-dir \
"cisco-ai-skill-scanner==${SKILL_SCANNER_VERSION}" \
"litellm==1.90.2" && \
python /tmp/apply_1_0_2_llm_base_url_backport.py /usr/local/lib/python3.11/site-packages && \
rm /tmp/apply_1_0_2_llm_base_url_backport.py && \
addgroup -S app && \
adduser -S app -G app && \
mkdir -p /tmp/skillhub-scans && \
chown app:app /tmp/skillhub-scans
COPY skillhub_scanner_app.py /app/skillhub_scanner_app.py
USER app
EXPOSE 8000
HEALTHCHECK --interval=10s --timeout=3s \
CMD wget -qO- http://127.0.0.1:8000/health || exit 1
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2).read()"]
CMD ["uvicorn", "skillhub_scanner_app:app", "--host", "0.0.0.0", "--port", "8000"]

View file

@ -1,62 +0,0 @@
#!/usr/bin/env python3
"""Backport SKILL_SCANNER_LLM_BASE_URL support into cisco-ai-skill-scanner 1.0.2."""
from __future__ import annotations
import re
import sys
from pathlib import Path
EXPECTED_DIST_INFO = "cisco_ai_skill_scanner-1.0.2.dist-info"
ROUTER_RELATIVE_PATH = Path("skill_scanner/api/router.py")
def replace_exact(content: str, old: str, new: str, expected_count: int, label: str) -> str:
actual_count = content.count(old)
if actual_count != expected_count:
raise SystemExit(f"Expected {expected_count} occurrences of {label}, found {actual_count}.")
return content.replace(old, new, expected_count)
def replace_regex(content: str, pattern: str, replacement: str, expected_count: int, label: str) -> str:
updated, actual_count = re.subn(pattern, replacement, content, count=expected_count, flags=re.MULTILINE)
if actual_count != expected_count:
raise SystemExit(f"Expected {expected_count} regex replacements for {label}, found {actual_count}.")
return updated
def main() -> int:
site_packages = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("/usr/local/lib/python3.11/site-packages")
dist_info = site_packages / EXPECTED_DIST_INFO
if not dist_info.exists():
raise SystemExit(f"Expected {EXPECTED_DIST_INFO} under {site_packages}, but it was not found.")
router_path = site_packages / ROUTER_RELATIVE_PATH
content = router_path.read_text(encoding="utf-8")
content = replace_regex(
content,
r'^(?P<indent>\s*)llm_model = os.getenv\("SKILL_SCANNER_LLM_MODEL"\)$',
r'\g<0>\n\g<indent>llm_base_url = os.getenv("SKILL_SCANNER_LLM_BASE_URL")',
2,
"llm_model environment lookup",
)
content = replace_exact(
content,
"LLMAnalyzer(model=llm_model)",
"LLMAnalyzer(model=llm_model, base_url=llm_base_url)",
2,
"LLMAnalyzer model constructor",
)
content = replace_exact(
content,
"LLMAnalyzer(provider=provider_str)",
"LLMAnalyzer(provider=provider_str, base_url=llm_base_url)",
2,
"LLMAnalyzer provider constructor",
)
router_path.write_text(content, encoding="utf-8")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,36 +1,153 @@
"""Runtime safeguards around the upstream Cisco Skill Scanner ASGI application."""
import asyncio
import inspect
import logging
import os
import shutil
import tempfile
from functools import wraps
from importlib import import_module
from pathlib import Path
from typing import NoReturn
_RUNTIME_TEMP_ROOT = Path(
os.getenv("SKILLHUB_SCANNER_RUNTIME_TEMP_ROOT", "/tmp/skillhub-scanner-runtime")
)
def _prepare_runtime_temp_root() -> None:
"""Recreate the scanner-owned temp root before upstream allocates request directories."""
if _RUNTIME_TEMP_ROOT.name != "skillhub-scanner-runtime" or _RUNTIME_TEMP_ROOT.is_symlink():
raise RuntimeError("Scanner runtime temp root must be a non-symlink skillhub-scanner-runtime directory")
if _RUNTIME_TEMP_ROOT.exists():
if not _RUNTIME_TEMP_ROOT.is_dir():
raise RuntimeError("Scanner runtime temp root must be a directory")
shutil.rmtree(_RUNTIME_TEMP_ROOT)
_RUNTIME_TEMP_ROOT.mkdir(parents=True, mode=0o700)
_RUNTIME_TEMP_ROOT.chmod(0o700)
tempfile.tempdir = str(_RUNTIME_TEMP_ROOT)
_prepare_runtime_temp_root()
from fastapi import Request
from fastapi.responses import JSONResponse
from skill_scanner.api.api import app
from skill_scanner.cli import cli as _upstream_cli
_upstream_router = import_module("skill_scanner.api.router")
_MAX_CONCURRENT_SCANS = max(1, int(os.getenv("SKILLHUB_SCANNER_MAX_CONCURRENT_SCANS", "1")))
_HARD_TIMEOUT_SECONDS = max(1, int(os.getenv("SKILLHUB_SCANNER_HARD_TIMEOUT_SECONDS", "930")))
_upstream_router.MAX_UPLOAD_SIZE_BYTES = max(
1, int(os.getenv("SKILLHUB_SCANNER_MAX_UPLOAD_SIZE_BYTES", "110100480"))
)
_active_scans = 0
_active_scans_guard = asyncio.Lock()
_SCAN_PATHS = {"/scan", "/scan-upload"}
_REDACTION_MARKER = "_skillhub_redaction_installed"
_log = logging.getLogger(__name__)
def _cleanup_stale_scan_directories(temp_root: Path | None = None) -> None:
"""Remove incomplete upstream extraction directories left by a process restart."""
root = temp_root or Path(tempfile.gettempdir())
for candidate in root.glob("skill_scanner_*"):
if not candidate.is_dir():
def _redact_finding_text(message: str) -> str:
"""Redact credentials without applying CLI-only truncation or control escaping."""
redacted = _upstream_cli._STATUS_PRIVATE_KEY_RE.sub("<redacted>", message)
for pattern in (
_upstream_cli._STATUS_URL_USERINFO_RE,
_upstream_cli._STATUS_URL_TOKEN_USERINFO_RE,
_upstream_cli._STATUS_QUERY_SECRET_RE,
_upstream_cli._STATUS_BEARER_SECRET_RE,
_upstream_cli._STATUS_LABELED_SECRET_RE,
):
redacted = pattern.sub(_upstream_cli._replace_status_secret, redacted)
redacted = _upstream_cli._STATUS_PROVIDER_SECRET_RE.sub("<redacted>", redacted)
return _upstream_cli._STATUS_JWT_RE.sub("<redacted>", redacted)
def _redact_supported_tokens(value):
if isinstance(value, str):
return _redact_finding_text(value)
if isinstance(value, list):
return [_redact_supported_tokens(item) for item in value]
if isinstance(value, dict):
return {key: _redact_supported_tokens(item) for key, item in value.items()}
return value
def _iter_route_objects(container, seen=None):
"""Walk FastAPI/Starlette route containers, including mounted child routers."""
seen = set() if seen is None else seen
routes = getattr(container, "routes", None)
if routes is None:
nested_router = getattr(container, "router", None)
if nested_router is not None and nested_router is not container:
yield from _iter_route_objects(nested_router, seen)
return
for route in routes:
route_id = id(route)
if route_id in seen:
continue
try:
shutil.rmtree(candidate)
except OSError as error:
_log.warning("Could not remove stale scanner directory %s: %s", candidate, error)
seen.add(route_id)
yield route
for nested in (
getattr(route, "original_router", None),
getattr(route, "app", None),
getattr(route, "router", None),
):
yield from _iter_route_objects(nested, seen)
def _redact_scan_response(response):
findings = getattr(response, "findings", None)
if isinstance(findings, list):
response.findings = _redact_supported_tokens(findings)
return response
def _make_redacting_endpoint(endpoint):
if inspect.iscoroutinefunction(endpoint):
@wraps(endpoint)
async def redacting_endpoint(*args, __endpoint=endpoint, **kwargs):
return _redact_scan_response(await __endpoint(*args, **kwargs))
else:
@wraps(endpoint)
def redacting_endpoint(*args, __endpoint=endpoint, **kwargs):
return _redact_scan_response(__endpoint(*args, **kwargs))
return redacting_endpoint
def _install_scan_response_redaction() -> None:
"""Redact supported token forms before FastAPI serializes scan findings."""
upstream_routes = list(_iter_route_objects(_upstream_router.router))
target_routes = [
route
for route in upstream_routes
if getattr(route, "path", None) in _SCAN_PATHS
and "POST" in getattr(route, "methods", set())
]
if not target_routes:
raise RuntimeError("Scanner routes /scan and /scan-upload were not found")
target_endpoints = {route.endpoint for route in target_routes}
target_paths = {route.path for route in target_routes}
all_routes = [
*list(_iter_route_objects(getattr(app, "router", app))),
*upstream_routes,
]
for route in all_routes:
endpoint = getattr(route, "endpoint", None)
path = getattr(route, "path", None)
is_target_endpoint = endpoint in target_endpoints
is_target_path = path in target_paths and "POST" in getattr(route, "methods", set())
if not (is_target_endpoint or is_target_path) or getattr(route, _REDACTION_MARKER, False):
continue
wrapped_endpoint = _make_redacting_endpoint(endpoint)
route.endpoint = wrapped_endpoint
dependant = getattr(route, "dependant", None)
if dependant is not None:
dependant.call = wrapped_endpoint
setattr(route, _REDACTION_MARKER, True)
def _restart_after_hard_timeout(request_path: str) -> NoReturn:
@ -54,7 +171,7 @@ async def _await_scan_until(scan_task: asyncio.Task, deadline: float, request_pa
_restart_after_hard_timeout(request_path)
app.router.add_event_handler("startup", _cleanup_stale_scan_directories)
_install_scan_response_redaction()
@app.middleware("http")

View file

@ -1,5 +1,8 @@
import asyncio
import importlib.util
import os
import re
import shutil
import sys
import tempfile
import types
@ -9,8 +12,9 @@ from unittest.mock import patch
class _FakeRouter:
def __init__(self):
def __init__(self, routes=None):
self.handlers = []
self.routes = routes or []
def add_event_handler(self, _event, _handler):
self.handlers.append((_event, _handler))
@ -19,6 +23,39 @@ class _FakeRouter:
class _FakeApp:
def __init__(self):
self.router = _FakeRouter()
self.routes = [object()]
github_canary = "ghp_" + "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8"
openai_canary = "sk-proj-" + "Z9y8X7w6V5u4T3s2R1q0" * 3
aws_canary = "AKIA1234567890ABCDEF"
jwt_canary = "eyJabcde.abcdefgh.ijklmnop"
labeled_canary = "custom-secret-1234567890"
private_key_canary = "-----BEGIN PRIVATE KEY-----\nabc123\n-----END PRIVATE KEY-----"
self.upstream_routes = [
_FakeRoute(
"/scan",
_FakeScanResponse(
[
{
"description": f"YARA match: {github_canary}",
"metadata": {
"openai": openai_canary,
"aws": aws_canary,
"authorization": f"Bearer {jwt_canary}",
"labeled": f"api_key={labeled_canary}",
"private_key": private_key_canary,
},
}
]
),
),
_FakeRoute(
"/scan-upload",
_FakeScanResponse([{"description": "No credentials", "metadata": {"safe": True}}]),
),
]
mounted_routes = [route.clone("/mounted") for route in self.upstream_routes]
nested_routes = [route.clone("/nested") for route in self.upstream_routes]
self.router.routes = [_FakeIncludedRouter(mounted_routes), _FakeMount(nested_routes)]
def middleware(self, _kind):
return lambda function: function
@ -31,30 +68,110 @@ class _FakeResponse:
self.headers = headers
class _FakeScanResponse:
def __init__(self, findings):
self.findings = findings
self.status_code = 200
self.headers = {"X-Contract": "preserved"}
class _FakeRoute:
def __init__(self, path, response):
self.path = path
self.methods = {"POST"}
self._response = response
async def endpoint():
return response
self.endpoint = endpoint
self.dependant = types.SimpleNamespace(call=endpoint)
def clone(self, prefix=""):
clone = _FakeRoute(f"{prefix}{self.path}", self._response)
clone.endpoint = self.endpoint
clone.dependant = types.SimpleNamespace(call=self.endpoint)
return clone
class _FakeIncludedRouter:
def __init__(self, routes):
self.original_router = types.SimpleNamespace(routes=routes)
class _FakeMount:
def __init__(self, routes):
self.app = types.SimpleNamespace(router=types.SimpleNamespace(routes=routes))
class _Request:
method = "POST"
url = types.SimpleNamespace(path="/scan-upload")
def _load_module():
def _load_module(environment=None):
fastapi = types.ModuleType("fastapi")
fastapi.Request = object
responses = types.ModuleType("fastapi.responses")
responses.JSONResponse = _FakeResponse
api = types.ModuleType("skill_scanner.api.api")
api.app = _FakeApp()
router = types.ModuleType("skill_scanner.api.router")
router.MAX_UPLOAD_SIZE_BYTES = -1
router.router = types.SimpleNamespace(routes=api.app.upstream_routes)
cli = types.ModuleType("skill_scanner.cli.cli")
cli._STATUS_PRIVATE_KEY_RE = re.compile(
r"-----BEGIN PRIVATE KEY-----[\s\S]*?-----END PRIVATE KEY-----"
)
cli._STATUS_URL_USERINFO_RE = re.compile(r"(?!)")
cli._STATUS_URL_TOKEN_USERINFO_RE = re.compile(r"(?!)")
cli._STATUS_QUERY_SECRET_RE = re.compile(r"(?!)")
cli._STATUS_BEARER_SECRET_RE = re.compile(
r"(?i)(?P<prefix>\bBearer\s+)(?P<value>[A-Za-z0-9._-]+)"
)
cli._STATUS_LABELED_SECRET_RE = re.compile(
r"(?i)(?P<prefix>\bapi_key=)(?P<value>[^\s]+)"
)
cli._STATUS_PROVIDER_SECRET_RE = re.compile(
r"\b(?:AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{20,255}|"
r"github_pat_[A-Za-z0-9_]{20,255}|sk-(?:proj-)?[A-Za-z0-9_-]{20,255})\b"
)
cli._STATUS_JWT_RE = re.compile(r"\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\b")
def replace_status_secret(match):
return f"{match.group('prefix')}<redacted>{match.groupdict().get('suffix', '')}"
cli._replace_status_secret = replace_status_secret
stubs = {
"fastapi": fastapi,
"fastapi.responses": responses,
"skill_scanner": types.ModuleType("skill_scanner"),
"skill_scanner.api": types.ModuleType("skill_scanner.api"),
"skill_scanner.api.api": api,
"skill_scanner.api.router": router,
"skill_scanner.cli": types.ModuleType("skill_scanner.cli"),
"skill_scanner.cli.cli": cli,
}
with patch.dict(sys.modules, stubs):
test_temp_parent = Path(tempfile.mkdtemp(prefix="skillhub-scanner-wrapper-test-"))
runtime_temp_root = test_temp_parent / "skillhub-scanner-runtime"
runtime_temp_root.mkdir()
(runtime_temp_root / "stale-upload.zip").write_text("stale", encoding="utf-8")
(test_temp_parent / "outside.txt").write_text("keep", encoding="utf-8")
module_environment = {
"SKILLHUB_SCANNER_RUNTIME_TEMP_ROOT": str(runtime_temp_root),
**(environment or {}),
}
previous_tempdir = tempfile.tempdir
with patch.dict(os.environ, module_environment, clear=True), patch.dict(sys.modules, stubs):
module_path = Path(__file__).parents[1] / "skillhub_scanner_app.py"
spec = importlib.util.spec_from_file_location("skillhub_scanner_app_under_test", module_path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
try:
spec.loader.exec_module(module)
finally:
tempfile.tempdir = previous_tempdir
module._router_stub = router
module._test_temp_parent = test_temp_parent
return module
@ -62,6 +179,9 @@ class SkillHubScannerAppTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.module = _load_module()
def tearDown(self):
shutil.rmtree(self.module._test_temp_parent)
async def test_excess_scan_is_rejected(self):
self.module._active_scans = 1
@ -107,24 +227,102 @@ class SkillHubScannerAppTest(unittest.IsolatedAsyncioTestCase):
restart.assert_called_once_with("/scan-upload")
self.assertEqual(0, self.module._active_scans)
async def test_startup_cleanup_removes_only_scanner_directories(self):
with tempfile.TemporaryDirectory() as temp_root:
root = Path(temp_root)
stale = root / "skill_scanner_abcd"
unrelated = root / "skillhub-data"
stale.mkdir()
unrelated.mkdir()
async def test_default_upload_limit_matches_skillhub_package_limit(self):
self.assertEqual(110100480, self.module._router_stub.MAX_UPLOAD_SIZE_BYTES)
self.module._cleanup_stale_scan_directories(root)
async def test_upload_limit_can_be_overridden_by_environment(self):
module = _load_module({"SKILLHUB_SCANNER_MAX_UPLOAD_SIZE_BYTES": "123456"})
self.addCleanup(shutil.rmtree, module._test_temp_parent)
self.assertFalse(stale.exists())
self.assertTrue(unrelated.exists())
self.assertEqual(123456, module._router_stub.MAX_UPLOAD_SIZE_BYTES)
async def test_startup_cleanup_is_registered_on_the_upstream_router(self):
self.assertEqual(
[("startup", self.module._cleanup_stale_scan_directories)],
self.module.app.router.handlers,
)
async def test_upload_limit_is_at_least_one_byte(self):
module = _load_module({"SKILLHUB_SCANNER_MAX_UPLOAD_SIZE_BYTES": "0"})
self.addCleanup(shutil.rmtree, module._test_temp_parent)
self.assertEqual(1, module._router_stub.MAX_UPLOAD_SIZE_BYTES)
async def test_scan_findings_redact_credentials_without_changing_response_contract(self):
route = next(route for route in self.module._router_stub.router.routes if route.path == "/scan")
response = await route.endpoint()
self.assertNotIn("ghp_", str(response.findings))
self.assertNotIn("sk-proj-", str(response.findings))
self.assertNotIn("AKIA1234567890ABCDEF", str(response.findings))
self.assertNotIn("eyJabcde.abcdefgh.ijklmnop", str(response.findings))
self.assertNotIn("custom-secret-1234567890", str(response.findings))
self.assertNotIn("BEGIN PRIVATE KEY", str(response.findings))
self.assertEqual(200, response.status_code)
self.assertEqual({"X-Contract": "preserved"}, response.headers)
async def test_mounted_app_route_also_redacts_findings(self):
included = next(route for route in self.module.app.router.routes if isinstance(route, _FakeIncludedRouter))
route = next(route for route in included.original_router.routes if route.path == "/mounted/scan")
response = await route.endpoint()
self.assertNotIn("ghp_", str(response.findings))
self.assertNotIn("sk-proj-", str(response.findings))
self.assertEqual(200, response.status_code)
self.assertEqual({"X-Contract": "preserved"}, response.headers)
async def test_nested_fastapi_app_route_also_redacts_findings(self):
mount = next(route for route in self.module.app.router.routes if isinstance(route, _FakeMount))
route = next(route for route in mount.app.router.routes if route.path == "/nested/scan")
response = await route.endpoint()
self.assertNotIn("ghp_", str(response.findings))
self.assertNotIn("sk-proj-", str(response.findings))
self.assertEqual(200, response.status_code)
self.assertEqual({"X-Contract": "preserved"}, response.headers)
async def test_sync_endpoint_wrapper_redacts_findings(self):
response = _FakeScanResponse([{"description": "api_key=custom-secret-1234567890"}])
def endpoint():
return response
wrapped = self.module._make_redacting_endpoint(endpoint)
self.assertIs(response, wrapped())
self.assertNotIn("custom-secret-1234567890", str(response.findings))
async def test_safe_scan_findings_are_unchanged(self):
route = next(route for route in self.module._router_stub.router.routes if route.path == "/scan-upload")
expected = [{"description": "No credentials", "metadata": {"safe": True}}]
response = await route.dependant.call()
self.assertEqual(expected, response.findings)
async def test_redaction_preserves_safe_multiline_and_long_finding_text(self):
safe_multiline = "line one\n\tline two"
safe_long = "x" * 5000
redacted = self.module._redact_supported_tokens([safe_multiline, safe_long])
self.assertEqual([safe_multiline, safe_long], redacted)
async def test_redaction_preserves_non_secret_control_characters_around_secret(self):
value = "before\napi_key=custom-secret-1234567890\tafter"
redacted = self.module._redact_supported_tokens(value)
self.assertEqual("before\napi_key=<redacted>\tafter", redacted)
async def test_startup_does_not_register_global_temp_directory_cleanup(self):
self.assertEqual([], self.module.app.router.handlers)
async def test_import_recreates_private_runtime_temp_root_without_touching_parent(self):
runtime_root = self.module._RUNTIME_TEMP_ROOT
outside = self.module._test_temp_parent / "outside.txt"
self.assertTrue(runtime_root.is_dir())
self.assertEqual(0o700, runtime_root.stat().st_mode & 0o777)
self.assertFalse((runtime_root / "stale-upload.zip").exists())
self.assertEqual("keep", outside.read_text(encoding="utf-8"))
if __name__ == "__main__":

View file

@ -0,0 +1,384 @@
#!/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"

View file

@ -1,232 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SCANNER_DIR="$REPO_ROOT/scanner"
TMP_DIRS=()
cleanup() {
local status=$?
local d
for d in "${TMP_DIRS[@]+"${TMP_DIRS[@]}"}"; do
rm -rf "$d"
done
exit "$status"
}
trap cleanup EXIT
new_tmp() {
local d
d="$(mktemp -d)"
TMP_DIRS+=("$d")
echo "$d"
}
fail() {
echo "FAIL: $*" >&2
exit 1
}
tmp="$(new_tmp)"
skill_dir="$tmp/skill"
mkdir -p "$skill_dir/demo-skill"
cat >"$skill_dir/demo-skill/SKILL.md" <<'EOF'
---
name: demo-skill
description: Minimal valid skill used for scanner integration coverage.
license: Apache-2.0
---
This is a harmless demo skill used for scanner integration testing.
EOF
cat >"$skill_dir/demo-skill/run.sh" <<'EOF'
#!/usr/bin/env sh
echo "demo"
EOF
chmod +x "$skill_dir/demo-skill/run.sh"
IMAGE_TAG="skillhub-scanner-llm-base-url-test:$(date +%s)"
docker build --no-cache -t "$IMAGE_TAG" "$SCANNER_DIR" >/dev/null
docker run --rm -i \
-v "$skill_dir:/work/skill:ro" \
--entrypoint python \
"$IMAGE_TAG" - <<'PY'
import asyncio
from datetime import datetime, timezone
import http.server
import io
import inspect
import json
import os
from pathlib import Path
import threading
import urllib.request
import zipfile
from fastapi.params import Query
from skill_scanner.core.models import ScanResult
import skill_scanner.api.router as router
signature = inspect.signature(router.scan_uploaded_skill)
if not isinstance(signature.parameters["use_llm"].default, Query):
raise SystemExit("scan-upload use_llm should remain a Query parameter")
if not isinstance(signature.parameters["llm_provider"].default, Query):
raise SystemExit("scan-upload llm_provider should remain a Query parameter")
state = {"base_urls": [], "paths": []}
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, format, *args): # noqa: A003
return
def do_POST(self): # noqa: N802
length = int(self.headers.get("content-length", "0"))
self.rfile.read(length)
state["paths"].append(self.path)
payload = json.dumps(
{
"id": "chatcmpl-test",
"object": "chat.completion",
"created": int(datetime.now(timezone.utc).timestamp()),
"model": "local-model",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "No findings."},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
}
).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
server = http.server.HTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
target_base_url = f"http://127.0.0.1:{server.server_port}/v1"
os.environ["SKILL_SCANNER_LLM_BASE_URL"] = target_base_url
os.environ["SKILL_SCANNER_LLM_MODEL"] = "test-model"
class FakeStaticAnalyzer:
pass
class FakeLLMAnalyzer:
def __init__(self, model=None, provider=None, base_url=None):
self.model = model
self.provider = provider
self.base_url = base_url
state["base_urls"].append(base_url)
def analyze(self, skill_path):
request = urllib.request.Request(
self.base_url + "/chat/completions",
data=b"{}",
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(request, timeout=5) as response:
response.read()
class FakeSkillScanner:
def __init__(self, analyzers):
self.analyzers = analyzers
def scan_skill(self, skill_path):
for analyzer in self.analyzers:
analyze = getattr(analyzer, "analyze", None)
if callable(analyze):
analyze(skill_path)
return ScanResult(
skill_name="demo-skill",
skill_directory=str(skill_path),
findings=[],
scan_duration_seconds=0.05,
analyzers_used=["fake-llm"],
timestamp=datetime.now(timezone.utc),
)
router.StaticAnalyzer = FakeStaticAnalyzer
router.LLMAnalyzer = FakeLLMAnalyzer
router.SkillScanner = FakeSkillScanner
router.LLM_AVAILABLE = True
request = router.ScanRequest(
skill_directory="/work/skill/demo-skill",
use_llm=True,
llm_provider="openai",
use_behavioral=False,
use_aidefense=False,
aidefense_api_key=None,
)
def build_skill_archive_bytes(skill_root: str) -> bytes:
skill_path = Path(skill_root)
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive:
for path in skill_path.rglob("*"):
if path.is_file():
archive.writestr(str(path.relative_to(skill_path.parent)), path.read_bytes())
return buffer.getvalue()
class FakeUploadFile:
def __init__(self, filename: str, payload: bytes):
self.filename = filename
self._payload = payload
async def read(self) -> bytes:
return self._payload
try:
direct_response = asyncio.run(router.scan_skill(request))
upload_response = asyncio.run(
router.scan_uploaded_skill(
file=FakeUploadFile("demo-skill.zip", build_skill_archive_bytes("/work/skill/demo-skill")),
use_llm=True,
llm_provider="openai",
use_behavioral=False,
use_aidefense=False,
aidefense_api_key=None,
)
)
finally:
server.shutdown()
thread.join(timeout=5)
if not getattr(direct_response, "scan_id", None):
raise SystemExit("scan_skill should still return a scan response")
if not getattr(upload_response, "scan_id", None):
raise SystemExit("scan_uploaded_skill should still return a scan response")
if len(state["base_urls"]) != 2:
raise SystemExit(f"expected two LLM analyzer constructions, got {len(state['base_urls'])}")
if any(base_url != target_base_url for base_url in state["base_urls"]):
raise SystemExit(f"expected every base_url to be {target_base_url}, got {state['base_urls']}")
if len(state["paths"]) != 2:
raise SystemExit(f"expected two LLM requests, got {state['paths']}")
if not all(path.startswith("/v1/") for path in state["paths"]):
raise SystemExit(f"expected every request path to start with /v1/, got {state['paths']}")
PY
grep -Fq "name: SKILL_SCANNER_LLM_BASE_URL" "$REPO_ROOT/deploy/k8s/base/scanner-deployment.yaml" \
|| fail "Kubernetes scanner deployment must expose SKILL_SCANNER_LLM_BASE_URL"
grep -Fq "skill-scanner-llm-base-url" "$REPO_ROOT/deploy/k8s/base/secret.yaml.example" \
|| fail "Kubernetes secret example must document skill-scanner-llm-base-url"
echo "scanner-llm-base-url-test passed"

View file

@ -91,6 +91,8 @@ public class SkillScannerConfig {
analyzers.isBehavioral(),
analyzers.isLlm(),
analyzers.getLlmProvider(),
analyzers.getLlmConsensusRuns(),
properties.getPolicy().getPreset(),
analyzers.isMeta(),
analyzers.isAiDefense(),
analyzers.getAiDefenseApiKey(),

View file

@ -40,7 +40,10 @@ import static org.springframework.security.test.web.servlet.request.SecurityMock
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not;
@SpringBootTest
@AutoConfigureMockMvc
@ -128,6 +131,29 @@ class SecurityAuditControllerTest {
.andExpect(jsonPath("$.data[0].findings[0].ruleId").value("STATIC-001"));
}
@Test
void getSecurityAudit_doesNotExposeUnmaskedCanaryFromStoredFinding() throws Exception {
SecurityAudit audit = new SecurityAudit(42L, ScannerType.SKILL_SCANNER);
setField(audit, "id", 8L);
audit.setScanId("scan-masked");
audit.setVerdict(SecurityVerdict.DANGEROUS);
audit.setIsSafe(false);
audit.setMaxSeverity("HIGH");
audit.setFindings("""
[{"ruleId":"TOKEN-001","severity":"HIGH","category":"secrets","title":"Token detected","message":"<redacted>","filePath":"SKILL.md","lineNumber":4,"codeSnippet":"token=<redacted>"}]
""".trim());
given(skillVersionRepository.findById(42L)).willReturn(java.util.Optional.of(skillVersion(42L, 8L)));
given(skillRepository.findById(8L)).willReturn(java.util.Optional.of(skill(8L, "reviewer-1")));
given(securityAuditRepository.findLatestActiveByVersionId(42L)).willReturn(List.of(audit));
mockMvc.perform(get("/api/v1/skills/8/versions/42/security-audit")
.with(auth("reviewer-1"))
.requestAttr("userNsRoles", Map.of(5L, NamespaceRole.ADMIN)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data[0].findings[0].message").value("<redacted>"))
.andExpect(content().string(not(containsString("ghp_012345678901234567890123456789012345"))));
}
@Test
void getSecurityAudit_returnsEmptyListWhenAuditMissing() throws Exception {
given(skillVersionRepository.findById(42L)).willReturn(java.util.Optional.of(skillVersion(42L, 8L)));

View file

@ -18,6 +18,7 @@ import org.springframework.transaction.support.TransactionSynchronizationManager
import java.lang.reflect.Field;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
@ -404,6 +405,29 @@ class SecurityScanServiceTest {
verify(skillVersionRepository).save(version);
}
@Test
void processScanResult_persistsScannerMaskedFindingWithoutOriginalCanary() {
SecurityAudit audit = new SecurityAudit(42L, ScannerType.SKILL_SCANNER, "task-masked");
SkillVersion version = new SkillVersion(8L, "1.0.0", "publisher-1");
version.setStatus(SkillVersionStatus.SCANNING);
given(auditRepository.findByTaskId("task-masked")).willReturn(Optional.of(audit));
given(auditRepository.findLatestActiveByVersionIdAndScannerType(42L, ScannerType.SKILL_SCANNER))
.willReturn(Optional.of(audit));
given(skillVersionRepository.findById(42L)).willReturn(Optional.of(version));
String canary = "ghp_012345678901234567890123456789012345";
SecurityFinding maskedFinding = new SecurityFinding(
"TOKEN-001", "HIGH", "secrets", "Token detected", "<redacted>",
"SKILL.md", 4, "token=<redacted>", "Rotate token", "static", Map.of());
service.processScanResult(
"task-masked", 42L, ScannerType.SKILL_SCANNER,
new SecurityScanResponse("scan-masked", SecurityVerdict.DANGEROUS, 1, "HIGH",
List.of(maskedFinding), 0.2));
assertThat(audit.getFindings()).contains("<redacted>").doesNotContain(canary);
}
@Test
void processScanResult_forStaleAttemptDoesNotCompleteCurrentAttempt() throws Exception {
SecurityAudit stale = new SecurityAudit(42L, ScannerType.SKILL_SCANNER, "task-stale");

View file

@ -7,7 +7,11 @@ public interface HttpClient {
<T> T get(String uri, Class<T> responseType);
<T> T post(String uri, Object body, Class<T> responseType);
default <T> T post(String uri, Object body, Class<T> responseType) {
return post(uri, body, new HttpHeaders(), responseType);
}
<T> T post(String uri, Object body, HttpHeaders headers, Class<T> responseType);
<T> T postMultipart(String uri, MultiValueMap<String, Object> parts, Class<T> responseType);

View file

@ -6,9 +6,11 @@ public class HttpClientException extends RuntimeException {
private final String responseBody;
public HttpClientException(int statusCode, String responseBody) {
super("HTTP " + statusCode + ": " + responseBody);
super(responseBody == null || responseBody.isBlank()
? "HTTP " + statusCode
: "HTTP " + statusCode + ": " + bounded(responseBody));
this.statusCode = statusCode;
this.responseBody = responseBody;
this.responseBody = responseBody == null ? null : bounded(responseBody);
}
public HttpClientException(String message, Throwable cause) {
@ -33,4 +35,8 @@ public class HttpClientException extends RuntimeException {
String message = root.getMessage();
return root.getClass().getSimpleName() + (message == null || message.isBlank() ? "" : ": " + message);
}
private static String bounded(String body) {
return body.length() <= 2048 ? body : body.substring(0, 2048) + "...[truncated]";
}
}

View file

@ -39,10 +39,16 @@ public class WebClientHttpClient implements HttpClient {
@Override
public <T> T post(String uri, Object body, Class<T> responseType) {
return post(uri, body, new HttpHeaders(), responseType);
}
@Override
public <T> T post(String uri, Object body, HttpHeaders headers, Class<T> responseType) {
log.debug("POST {}", uri);
try {
return webClient.post()
.uri(uri)
.headers(httpHeaders -> httpHeaders.addAll(headers))
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(body)
.retrieve()

View file

@ -4,6 +4,8 @@ public record ScanOptions(
boolean useBehavioral,
boolean useLlm,
String llmProvider,
int llmConsensusRuns,
String policyPreset,
boolean enableMeta,
boolean useAidefense,
String aidefenseApiKey,
@ -11,7 +13,39 @@ public record ScanOptions(
boolean useTrigger
) {
public ScanOptions {
if ("azure".equals(llmProvider)) {
llmProvider = "azure-openai";
}
if (!"anthropic".equals(llmProvider)
&& !"openai".equals(llmProvider)
&& !"azure-openai".equals(llmProvider)) {
throw new IllegalArgumentException("llmProvider must be anthropic, openai, or azure");
}
if (llmConsensusRuns < 1) {
throw new IllegalArgumentException("llmConsensusRuns must be at least 1");
}
if (!"strict".equals(policyPreset)
&& !"balanced".equals(policyPreset)
&& !"permissive".equals(policyPreset)) {
throw new IllegalArgumentException("policyPreset must be strict, balanced, or permissive");
}
}
/** Backward-compatible constructor for callers that do not configure the new scanner options. */
public ScanOptions(boolean useBehavioral,
boolean useLlm,
String llmProvider,
boolean enableMeta,
boolean useAidefense,
String aidefenseApiKey,
boolean useVirusTotal,
boolean useTrigger) {
this(useBehavioral, useLlm, llmProvider, 1, "balanced", enableMeta,
useAidefense, aidefenseApiKey, useVirusTotal, useTrigger);
}
public static ScanOptions disabled() {
return new ScanOptions(false, false, "anthropic", false, false, "", false, false);
return new ScanOptions(false, false, "anthropic", 1, "balanced", false, false, "", false, false);
}
}

View file

@ -38,25 +38,26 @@ public class SkillScannerService {
Map<String, Object> body = buildScanRequestBody(skillDirectory, options);
try {
return httpClient.post(uri, body, SkillScannerApiResponse.class);
return httpClient.post(uri, body, buildScannerHeaders(options), SkillScannerApiResponse.class);
} catch (HttpClientException e) {
log.error("Scanner API error: status={}, body={}", e.getStatusCode(), summarizeResponseBody(e.getResponseBody()));
throw e;
log.error("Scanner API error: status={}, operation=scanDirectory", e.getStatusCode());
throw sanitizedException(e);
}
}
public SkillScannerApiResponse scanUpload(Path skillPackagePath, ScanOptions options) {
String uri = buildUploadUri(options);
log.info("Uploading skill package to scanner: {}", sanitizeUri(uri));
log.info("Uploading skill package to scanner: {}", uri);
MultiValueMap<String, Object> parts = new LinkedMultiValueMap<>();
parts.add("file", new FileSystemResource(skillPackagePath));
addScannerOptionsParts(parts, options);
HttpHeaders headers = buildScannerHeaders(options);
try {
return httpClient.postMultipart(uri, parts, headers, SkillScannerApiResponse.class);
} catch (HttpClientException e) {
log.error("Scanner API error: status={}, body={}", e.getStatusCode(), summarizeResponseBody(e.getResponseBody()));
throw e;
log.error("Scanner API error: status={}, operation=scanUpload", e.getStatusCode());
throw sanitizedException(e);
}
}
@ -70,16 +71,27 @@ public class SkillScannerService {
body.put("use_behavioral", options.useBehavioral());
body.put("use_llm", options.useLlm());
body.put("llm_provider", options.llmProvider());
body.put("llm_consensus_runs", options.llmConsensusRuns());
body.put("policy", options.policyPreset());
body.put("enable_meta", options.enableMeta());
body.put("use_aidefense", options.useAidefense());
if (options.useAidefense() && !options.aidefenseApiKey().isEmpty()) {
body.put("aidefense_api_key", options.aidefenseApiKey());
}
body.put("use_virustotal", options.useVirusTotal());
body.put("use_trigger", options.useTrigger());
return body;
}
private void addScannerOptionsParts(MultiValueMap<String, Object> parts, ScanOptions options) {
parts.add("use_behavioral", Boolean.toString(options.useBehavioral()));
parts.add("use_llm", Boolean.toString(options.useLlm()));
parts.add("llm_provider", options.llmProvider());
parts.add("llm_consensus_runs", Integer.toString(options.llmConsensusRuns()));
parts.add("policy", options.policyPreset());
parts.add("enable_meta", Boolean.toString(options.enableMeta()));
parts.add("use_aidefense", Boolean.toString(options.useAidefense()));
parts.add("use_virustotal", Boolean.toString(options.useVirusTotal()));
parts.add("use_trigger", Boolean.toString(options.useTrigger()));
}
private String buildUploadUri(ScanOptions options) {
StringBuilder uri = new StringBuilder(baseUrl + scanPath);
uri.append("?use_behavioral=").append(options.useBehavioral());
@ -95,7 +107,7 @@ public class SkillScannerService {
private HttpHeaders buildScannerHeaders(ScanOptions options) {
HttpHeaders headers = new HttpHeaders();
if (options.useAidefense() && !options.aidefenseApiKey().isEmpty()) {
headers.add("X-AIDefense-Api-Key", options.aidefenseApiKey());
headers.add("X-AIDefense-Key", options.aidefenseApiKey());
}
return headers;
}
@ -116,15 +128,11 @@ public class SkillScannerService {
return normalized.endsWith("/") ? normalized.substring(0, normalized.length() - 1) : normalized;
}
private String summarizeResponseBody(String body) {
if (body == null || body.isBlank()) {
return "<empty>";
private HttpClientException sanitizedException(HttpClientException exception) {
if (exception.getStatusCode() > 0) {
return new HttpClientException(exception.getStatusCode(), null);
}
String singleLine = body.replaceAll("\\s+", " ").trim();
return singleLine.length() > 200 ? singleLine.substring(0, 200) + "...[truncated]" : singleLine;
}
private String sanitizeUri(String uri) {
return uri.replaceAll("([?&]aidefense_api_key=)[^&]+", "$1***");
Throwable cause = exception.getCause() == null ? exception : exception.getCause();
return new HttpClientException("Scanner API request failed", cause);
}
}

View file

@ -0,0 +1,80 @@
package com.iflytek.skillhub.infra.http;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.web.reactive.function.client.WebClient;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
class WebClientHttpClientTest {
private HttpServer server;
private AtomicReference<String> requestBody;
private AtomicReference<String> requestHeader;
@BeforeEach
void setUp() throws IOException {
requestBody = new AtomicReference<>();
requestHeader = new AtomicReference<>();
server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
server.createContext("/json", this::handleJson);
server.start();
}
@AfterEach
void tearDown() {
server.stop(0);
}
@Test
void postJson_sendsHeadersAndDecodesResponse() {
WebClientHttpClient client = new WebClientHttpClient(WebClient.builder().build());
@SuppressWarnings("unchecked")
Map<String, Object> response = (Map<String, Object>) client.post(
endpoint(),
Map.of("message", "hello"),
new org.springframework.http.HttpHeaders() {{ set("X-Test", "header-value"); }},
Map.class
);
assertThat(response).containsEntry("ok", true);
assertThat(requestHeader.get()).isEqualTo("header-value");
assertThat(requestBody.get()).contains("\"message\":\"hello\"");
}
@Test
void postJson_withoutHeaders_remainsSupported() {
WebClientHttpClient client = new WebClientHttpClient(WebClient.builder().build());
@SuppressWarnings("unchecked")
Map<String, Object> response = (Map<String, Object>) client.post(endpoint(), Map.of("message", "legacy"), Map.class);
assertThat(response).containsEntry("ok", true);
assertThat(requestHeader.get()).isNull();
}
private String endpoint() {
return "http://localhost:" + server.getAddress().getPort() + "/json";
}
private void handleJson(HttpExchange exchange) throws IOException {
requestBody.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
requestHeader.set(exchange.getRequestHeaders().getFirst("X-Test"));
byte[] response = "{\"ok\":true}".getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(200, response.length);
try (var output = exchange.getResponseBody()) {
output.write(response);
}
}
}

View file

@ -0,0 +1,49 @@
package com.iflytek.skillhub.infra.scanner;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class ScanOptionsTest {
@Test
void disabled_usesSafeConsensusAndBalancedPolicyDefaults() {
ScanOptions options = ScanOptions.disabled();
assertThat(options.llmConsensusRuns()).isEqualTo(1);
assertThat(options.policyPreset()).isEqualTo("balanced");
}
@Test
void rejectsInvalidConsensusRuns() {
assertThatThrownBy(() -> new ScanOptions(
false, false, "anthropic", 0, "balanced", false, false, "", false, false))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("llmConsensusRuns");
}
@Test
void rejectsUnknownPolicyPreset() {
assertThatThrownBy(() -> new ScanOptions(
false, false, "anthropic", 1, "custom", false, false, "", false, false))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("policyPreset");
}
@Test
void rejectsUnknownLlmProvider() {
assertThatThrownBy(() -> new ScanOptions(
false, false, "openai&unexpected=value", 1, "balanced", false, false, "", false, false))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("llmProvider");
}
@Test
void acceptsDocumentedAzureLlmProvider() {
ScanOptions options = new ScanOptions(
false, true, "azure", 1, "balanced", false, false, "", false, false);
assertThat(options.llmProvider()).isEqualTo("azure-openai");
}
}

View file

@ -168,6 +168,11 @@ class SkillScannerAdapterTest {
throw new UnsupportedOperationException();
}
@Override
public <T> T post(String uri, Object body, HttpHeaders headers, Class<T> responseType) {
throw new UnsupportedOperationException();
}
@Override
public <T> T postMultipart(String uri, org.springframework.util.MultiValueMap<String, Object> parts,
Class<T> responseType) {

View file

@ -122,6 +122,11 @@ class SkillScannerLoggingTest {
throw new UnsupportedOperationException();
}
@Override
public <T> T post(String uri, Object body, HttpHeaders headers, Class<T> responseType) {
throw new UnsupportedOperationException();
}
@Override
public <T> T postMultipart(String uri, MultiValueMap<String, Object> parts, Class<T> responseType) {
throw new UnsupportedOperationException();
@ -165,6 +170,11 @@ class SkillScannerLoggingTest {
throw new UnsupportedOperationException();
}
@Override
public <T> T post(String uri, Object body, HttpHeaders headers, Class<T> responseType) {
throw new UnsupportedOperationException();
}
@Override
public <T> T postMultipart(String uri, MultiValueMap<String, Object> parts, Class<T> responseType) {
throw new UnsupportedOperationException();

View file

@ -63,6 +63,8 @@ class SkillScannerServiceTest {
assertThat(body.get("skill_directory")).isEqualTo("/tmp/demo");
assertThat(body.get("use_behavioral")).isEqualTo(true);
assertThat(body.get("use_llm")).isEqualTo(false);
assertThat(body.get("llm_consensus_runs")).isEqualTo(1);
assertThat(body.get("policy")).isEqualTo("balanced");
}
@Test
@ -94,6 +96,8 @@ class SkillScannerServiceTest {
assertThat(httpClient.lastMultipartUri).contains("use_llm=true");
assertThat(httpClient.lastMultipartUri).contains("llm_provider=openai");
assertThat(httpClient.lastMultipartParts.getFirst("file")).isNotNull();
assertThat(httpClient.lastMultipartParts.getFirst("llm_consensus_runs")).isEqualTo("1");
assertThat(httpClient.lastMultipartParts.getFirst("policy")).isEqualTo("balanced");
}
@Test
@ -120,7 +124,28 @@ class SkillScannerServiceTest {
service.scanUpload(Path.of("/tmp/demo.zip"), options);
assertThat(httpClient.lastMultipartUri).doesNotContain("aidefense_api_key");
assertThat(httpClient.lastMultipartHeaders.getFirst("X-AIDefense-Api-Key")).isEqualTo("secret-key");
assertThat(httpClient.lastMultipartHeaders.getFirst("X-AIDefense-Key")).isEqualTo("secret-key");
}
@Test
void scanDirectory_sendsAidefenseApiKeyOnlyViaHeader() {
FakeHttpClient httpClient = new FakeHttpClient();
httpClient.postResponse = new SkillScannerApiResponse(
"scan-4", "test-skill", true, "LOW", 0, null, 0.5, "2026-03-22T07:00:00");
SkillScannerService service = new SkillScannerService(
httpClient, "http://scanner.test", "/scan-upload", "/health");
ScanOptions options = new ScanOptions(false, false, "anthropic", 3, "strict",
false, true, "secret-key", false, false);
service.scanDirectory("/tmp/demo", options);
@SuppressWarnings("unchecked")
Map<String, Object> body = (Map<String, Object>) httpClient.lastPostBody;
assertThat(body).doesNotContainKey("aidefense_api_key");
assertThat(httpClient.lastPostHeaders.getFirst("X-AIDefense-Key")).isEqualTo("secret-key");
assertThat(httpClient.lastPostUri).doesNotContain("secret-key");
assertThat(body.get("llm_consensus_runs")).isEqualTo(3);
assertThat(body.get("policy")).isEqualTo("strict");
}
@Test
@ -145,6 +170,7 @@ class SkillScannerServiceTest {
private Object multipartResponse;
private String lastPostUri;
private Object lastPostBody;
private HttpHeaders lastPostHeaders;
private String lastMultipartUri;
private MultiValueMap<String, Object> lastMultipartParts;
private HttpHeaders lastMultipartHeaders;
@ -164,6 +190,15 @@ class SkillScannerServiceTest {
return (T) postResponse;
}
@Override
@SuppressWarnings("unchecked")
public <T> T post(String uri, Object body, HttpHeaders headers, Class<T> responseType) {
this.lastPostUri = uri;
this.lastPostBody = body;
this.lastPostHeaders = headers;
return (T) postResponse;
}
@Override
@SuppressWarnings("unchecked")
public <T> T postMultipart(String uri, MultiValueMap<String, Object> parts, Class<T> responseType) {

View file

@ -0,0 +1,173 @@
import { expect, test, type Page, type Route } from '@playwright/test'
import { setEnglishLocale } from './helpers/auth-fixtures'
const CANARY = 'SYNTHETIC-CANARY-868'
const MASKED_SNIPPET = 'const token = "[REDACTED]"'
function envelope(data: unknown) {
return {
code: 0,
msg: 'success',
data,
timestamp: '2026-09-17T00:00:00Z',
requestId: 'security-audit-redaction-fixture',
}
}
async function fulfill(route: Route, data: unknown) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(envelope(data)),
})
}
async function mockSkillDetail(page: Page, audits: unknown[]) {
await page.route(/\/api\//, async (route) => {
const url = new URL(route.request().url())
const { pathname } = url
if (!pathname.startsWith('/api/')) {
await route.continue()
return
}
if (pathname === '/api/v1/auth/me') {
await fulfill(route, {
userId: 'audit-admin',
displayName: 'Audit Admin',
platformRoles: ['SUPER_ADMIN'],
oauthProvider: 'local',
canChangePassword: true,
})
return
}
if (pathname === '/api/web/skills/global/redaction-skill') {
await fulfill(route, {
id: 868,
slug: 'redaction-skill',
displayName: 'Redaction Skill',
ownerId: 'skill-owner',
ownerDisplayName: 'Skill Owner',
summary: 'Security audit redaction fixture',
visibility: 'PUBLIC',
status: 'ACTIVE',
downloadCount: 0,
starCount: 0,
ratingCount: 0,
hidden: false,
namespace: 'global',
canManageLifecycle: false,
canSubmitPromotion: false,
canInteract: false,
canReport: false,
headlineVersion: { id: 8681, version: '1.0.0', status: 'PUBLISHED' },
publishedVersion: { id: 8681, version: '1.0.0', status: 'PUBLISHED' },
resolutionMode: 'PUBLISHED',
labels: [],
})
return
}
if (pathname === '/api/web/skills/global/redaction-skill/versions') {
await fulfill(route, {
items: [{
id: 8681,
version: '1.0.0',
status: 'PUBLISHED',
fileCount: 0,
totalSize: 0,
publishedAt: '2026-09-17T00:00:00Z',
downloadAvailable: true,
}],
total: 1,
page: 0,
size: 20,
})
return
}
if (pathname === '/api/web/skills/global/redaction-skill/versions/1.0.0/files') {
await fulfill(route, [])
return
}
if (pathname === '/api/v1/skills/868/versions/8681/security-audit') {
await fulfill(route, audits)
return
}
if (pathname === '/api/web/skills/868/reviews') {
await fulfill(route, { items: [], total: 0, page: 0, size: 20 })
return
}
if (pathname === '/api/web/skills/868/reviews/me') {
await fulfill(route, null)
return
}
if (pathname === '/api/web/notifications/unread-count') {
await fulfill(route, { count: 0 })
return
}
await fulfill(route, [])
})
}
test.describe('Security audit redaction', () => {
test.beforeEach(async ({ page }) => {
await setEnglishLocale(page)
})
test('shows the precise SAFE verdict and only the masked finding snippet', async ({ page }) => {
await mockSkillDetail(page, [{
id: 1,
scanId: 'scan-868',
scannerType: 'builtin',
verdict: 'SAFE',
isSafe: true,
maxSeverity: 'MEDIUM',
findingsCount: 1,
findings: [{
ruleId: 'SECRET-001',
severity: 'MEDIUM',
category: 'secret',
title: 'Embedded credential',
message: 'A credential-like value was detected.',
filePath: 'scripts/deploy.ts',
lineNumber: 7,
codeSnippet: MASKED_SNIPPET,
remediation: 'Read credentials from the environment.',
analyzer: 'synthetic',
metadata: { originalSnippet: CANARY },
}],
scanDurationSeconds: 1.2,
failureReason: null,
scannedAt: '2026-09-17T00:00:00Z',
createdAt: '2026-09-17T00:00:00Z',
}])
await page.goto('/space/global/redaction-skill')
await expect(page.getByRole('heading', { name: 'Redaction Skill', exact: true }).first()).toBeVisible({ timeout: 15_000 })
await expect(page.getByText('No high-risk findings', { exact: true })).toBeVisible()
await page.getByRole('button', { name: 'View Details' }).click()
await page.getByRole('button', { name: 'Findings' }).click()
await expect(page.getByText(MASKED_SNIPPET, { exact: true })).toBeVisible()
await expect(page.locator('body')).not.toContainText(CANARY)
})
test('keeps the skill detail usable when the audit list is empty', async ({ page }) => {
await mockSkillDetail(page, [])
await page.goto('/space/global/redaction-skill')
await expect(page.getByRole('heading', { name: 'Redaction Skill', exact: true }).first()).toBeVisible({ timeout: 15_000 })
await expect(page.getByText('Security Audit', { exact: true })).toHaveCount(0)
await expect(page.locator('body')).not.toContainText(CANARY)
})
})

View file

@ -98,6 +98,20 @@ describe('FindingItem', () => {
expect(html).toContain('SELECT * FROM users WHERE id = ${input}')
})
it('renders a masked snippet without exposing the original canary', () => {
const html = renderToStaticMarkup(
<FindingItem
finding={createFinding({
codeSnippet: 'const token = "[REDACTED]"',
metadata: { originalSnippet: 'SYNTHETIC-CANARY-868' },
})}
/>,
)
expect(html).toContain('const token = &quot;[REDACTED]&quot;')
expect(html).not.toContain('SYNTHETIC-CANARY-868')
})
it('omits the code snippet when codeSnippet is null', () => {
const finding = createFinding({ codeSnippet: null })
const html = renderToStaticMarkup(<FindingItem finding={finding} />)

View file

@ -1835,7 +1835,7 @@
"remediation": "Remediation",
"viewDetails": "View Details",
"verdict": {
"SAFE": "Safe",
"SAFE": "No high-risk findings",
"SUSPICIOUS": "Suspicious",
"DANGEROUS": "Dangerous",
"BLOCKED": "High Risk"

View file

@ -1862,7 +1862,7 @@
"remediation": "Рекомендации",
"viewDetails": "Подробности",
"verdict": {
"SAFE": "Безопасно",
"SAFE": "Угроз высокого риска не обнаружено",
"SUSPICIOUS": "Подозрительно",
"DANGEROUS": "Опасно",
"BLOCKED": "Высокий риск"

View file

@ -1834,7 +1834,7 @@
"remediation": "修复建议",
"viewDetails": "查看详情",
"verdict": {
"SAFE": "安全",
"SAFE": "未发现高风险问题",
"SUSPICIOUS": "可疑",
"DANGEROUS": "危险",
"BLOCKED": "高风险"

View file

@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import en from './locales/en.json'
import zh from './locales/zh.json'
import ru from './locales/ru.json'
describe('security audit locales', () => {
it('defines the scanning label in both locales', () => {
@ -12,4 +13,19 @@ describe('security audit locales', () => {
expect(zh.securityAudit.verdict.BLOCKED).toBe('高风险')
expect(en.securityAudit.verdict.BLOCKED).toBe('High Risk')
})
it('uses the precise safe verdict wording in all locales', () => {
expect(en.securityAudit.verdict.SAFE).toBe('No high-risk findings')
expect(zh.securityAudit.verdict.SAFE).toBe('未发现高风险问题')
expect(ru.securityAudit.verdict.SAFE).toBe('Угроз высокого риска не обнаружено')
})
it('keeps verdict keys in parity across all locales', () => {
expect(Object.keys(en.securityAudit.verdict).sort()).toEqual(
Object.keys(zh.securityAudit.verdict).sort(),
)
expect(Object.keys(en.securityAudit.verdict).sort()).toEqual(
Object.keys(ru.securityAudit.verdict).sort(),
)
})
})