mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
Publish security dataset from live Convex export (#2800)
* feat(cli): restore skill sync command * feat: publish security dataset from live export
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
name: Security Dataset Snapshot
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
upload:
|
||||
description: "Upload sanitized dataset files to Hugging Face"
|
||||
required: true
|
||||
default: "false"
|
||||
type: choice
|
||||
options:
|
||||
- "false"
|
||||
- "true"
|
||||
limit:
|
||||
description: "Optional source artifact cap for validation runs"
|
||||
required: false
|
||||
default: ""
|
||||
hf-revision:
|
||||
description: "Hugging Face branch/revision to upload to"
|
||||
required: true
|
||||
default: "main"
|
||||
schedule:
|
||||
- cron: "17 9 * * *"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: clawhub-security-dataset-snapshot
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish-security-dataset:
|
||||
name: Publish sanitized security dataset
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout-minutes: 360
|
||||
environment: Production
|
||||
env:
|
||||
CONVEX_URL: ${{ vars.CONVEX_URL || vars.VITE_CONVEX_URL || 'https://wry-manatee-359.convex.cloud' }}
|
||||
SECURITY_SCAN_WORKER_TOKEN: ${{ secrets.SECURITY_SCAN_WORKER_TOKEN }}
|
||||
HF_DATASET_REPO: OpenClaw/clawhub-security-signals
|
||||
HF_OIDC_RESOURCE: datasets/OpenClaw/clawhub-security-signals
|
||||
HF_REVISION: ${{ inputs.hf-revision || 'main' }}
|
||||
HF_UPLOAD: ${{ github.event_name == 'schedule' || inputs.upload == 'true' }}
|
||||
SNAPSHOT_LIMIT: ${{ inputs.limit || '' }}
|
||||
SANITIZED_OUT_DIR: ${{ runner.temp }}/clawhub-security-dataset/sanitized
|
||||
WORK_DIR: ${{ runner.temp }}/clawhub-security-dataset
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
if: ${{ env.HF_UPLOAD == 'true' }}
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Check configuration
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "$SECURITY_SCAN_WORKER_TOKEN" ]]; then
|
||||
echo "::error::SECURITY_SCAN_WORKER_TOKEN is required"
|
||||
exit 1
|
||||
fi
|
||||
echo "Upload enabled: $HF_UPLOAD"
|
||||
echo "Convex URL: $CONVEX_URL"
|
||||
echo "Hugging Face repo: $HF_DATASET_REPO"
|
||||
echo "Hugging Face OIDC resource: $HF_OIDC_RESOURCE"
|
||||
echo "Hugging Face revision: $HF_REVISION"
|
||||
|
||||
- name: Export sanitized live dataset
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "$SANITIZED_OUT_DIR"
|
||||
source_snapshot_id="live-convex-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
args=(
|
||||
--convex-url "$CONVEX_URL"
|
||||
--worker-token "$SECURITY_SCAN_WORKER_TOKEN"
|
||||
--source-snapshot-id "$source_snapshot_id"
|
||||
--out-dir "$SANITIZED_OUT_DIR"
|
||||
--hf-dataset
|
||||
--hf-repo "$HF_DATASET_REPO"
|
||||
--hf-revision "$HF_REVISION"
|
||||
)
|
||||
if [[ -n "$SNAPSHOT_LIMIT" ]]; then
|
||||
args+=(--limit "$SNAPSHOT_LIMIT")
|
||||
fi
|
||||
bun scripts/security-dataset/export-snapshot.ts "${args[@]}" | tee "$SANITIZED_OUT_DIR/summary.json"
|
||||
snapshot_dir="$(jq -r '.snapshotDir' "$SANITIZED_OUT_DIR/summary.json")"
|
||||
if [[ -z "$snapshot_dir" || "$snapshot_dir" == "null" ]]; then
|
||||
echo "::error::export summary did not include snapshotDir"
|
||||
exit 1
|
||||
fi
|
||||
echo "SNAPSHOT_DIR=$snapshot_dir" >> "$GITHUB_ENV"
|
||||
jq '.manifest.row_counts, .manifest.huggingface_dataset' "$SANITIZED_OUT_DIR/summary.json"
|
||||
|
||||
- name: Validate sanitized output guardrails
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -d "$SNAPSHOT_DIR/hf-dataset/data"
|
||||
test -f "$SNAPSHOT_DIR/manifest.json"
|
||||
for split in train validation test eval_holdout; do
|
||||
test -f "$SNAPSHOT_DIR/hf-dataset/data/$split.jsonl"
|
||||
done
|
||||
if grep -R -E 'storageId|skillVersions:|packageReleases:|_storage/' "$SNAPSHOT_DIR/hf-dataset" "$SNAPSHOT_DIR/manifest.json"; then
|
||||
echo "::error::sanitized output contains raw storage or internal document identifiers"
|
||||
exit 1
|
||||
fi
|
||||
if grep -R -E 'gh[pousr]_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{20,}|AKIA[0-9A-Z]{16}|-----BEGIN [A-Z0-9 ]*(PRIVATE KEY|CERTIFICATE)-----' "$SNAPSHOT_DIR/hf-dataset"; then
|
||||
echo "::error::sanitized output contains obvious secret-like values"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Install Hugging Face uploader
|
||||
if: ${{ env.HF_UPLOAD == 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install 'huggingface_hub[hf_xet]'
|
||||
|
||||
- name: Upload sanitized dataset to Hugging Face
|
||||
if: ${{ env.HF_UPLOAD == 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from huggingface_hub import HfApi
|
||||
|
||||
def get_github_oidc_token() -> str:
|
||||
request_url = os.environ["ACTIONS_ID_TOKEN_REQUEST_URL"]
|
||||
separator = "&" if "?" in request_url else "?"
|
||||
request = urllib.request.Request(
|
||||
f"{request_url}{separator}audience=https://huggingface.co",
|
||||
headers={
|
||||
"Authorization": f"bearer {os.environ['ACTIONS_ID_TOKEN_REQUEST_TOKEN']}",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(request) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
return payload["value"]
|
||||
|
||||
def exchange_hugging_face_token(oidc_token: str, resource: str) -> str:
|
||||
body = json.dumps({
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
|
||||
"subject_token_type": "urn:ietf:params:oauth:token-type:id_token",
|
||||
"subject_token": oidc_token,
|
||||
"resource": resource,
|
||||
}).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
"https://huggingface.co/oauth/token",
|
||||
data=body,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(request) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
return payload["access_token"]
|
||||
|
||||
snapshot_dir = Path(os.environ["SNAPSHOT_DIR"])
|
||||
repo_id = os.environ["HF_DATASET_REPO"]
|
||||
revision = os.environ["HF_REVISION"]
|
||||
token = exchange_hugging_face_token(
|
||||
get_github_oidc_token(),
|
||||
os.environ["HF_OIDC_RESOURCE"],
|
||||
)
|
||||
api = HfApi(token=token)
|
||||
|
||||
data_commit = api.upload_folder(
|
||||
folder_path=str(snapshot_dir / "hf-dataset" / "data"),
|
||||
path_in_repo="data",
|
||||
repo_id=repo_id,
|
||||
repo_type="dataset",
|
||||
revision=revision,
|
||||
delete_patterns="data/*.jsonl",
|
||||
commit_message="Update nightly ClawHub security dataset splits",
|
||||
)
|
||||
|
||||
manifest_path = snapshot_dir / "manifest.json"
|
||||
manifest = json.loads(manifest_path.read_text())
|
||||
manifest["huggingface_dataset"]["commit"] = data_commit.oid
|
||||
manifest["huggingface_dataset"]["revision"] = revision
|
||||
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n")
|
||||
|
||||
manifest_commit = api.upload_file(
|
||||
path_or_fileobj=str(manifest_path),
|
||||
path_in_repo="metadata/latest-manifest.json",
|
||||
repo_id=repo_id,
|
||||
repo_type="dataset",
|
||||
revision=revision,
|
||||
commit_message="Update nightly ClawHub security dataset manifest",
|
||||
)
|
||||
print(json.dumps({
|
||||
"data_commit": data_commit.oid,
|
||||
"manifest_commit": manifest_commit.oid,
|
||||
"repo": repo_id,
|
||||
"revision": revision,
|
||||
}, indent=2))
|
||||
PY
|
||||
|
||||
- name: Upload sanitized summary artifact
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: security-dataset-summary-${{ github.run_id }}
|
||||
path: |
|
||||
${{ env.SANITIZED_OUT_DIR }}/summary.json
|
||||
${{ env.SNAPSHOT_DIR }}/manifest.json
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Cleanup transient dataset files
|
||||
if: ${{ always() }}
|
||||
run: rm -rf "$WORK_DIR"
|
||||
@@ -0,0 +1,130 @@
|
||||
/* @vitest-environment node */
|
||||
import { gunzipSync } from "node:zlib";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { listArtifactExportBatchCompressed } from "./securityDatasetNode";
|
||||
|
||||
type WrappedHandler<TArgs, TResult = unknown> = {
|
||||
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
|
||||
};
|
||||
|
||||
const listArtifactExportBatchCompressedHandler = (
|
||||
listArtifactExportBatchCompressed as unknown as WrappedHandler<
|
||||
{
|
||||
token: string;
|
||||
sourceKind: "skill";
|
||||
paginationOpts: { cursor: string | null; numItems: number };
|
||||
pageCount: number;
|
||||
},
|
||||
{ encoding: "gzip-base64-json"; payload: string }
|
||||
>
|
||||
)._handler;
|
||||
|
||||
describe("security dataset worker export", () => {
|
||||
it("requires the shared worker token", async () => {
|
||||
vi.stubEnv("SECURITY_SCAN_WORKER_TOKEN", "worker-secret");
|
||||
|
||||
await expect(
|
||||
listArtifactExportBatchCompressedHandler(makeCtx(), {
|
||||
token: "wrong",
|
||||
sourceKind: "skill",
|
||||
paginationOpts: { cursor: null, numItems: 1 },
|
||||
pageCount: 1,
|
||||
}),
|
||||
).rejects.toThrow("Unauthorized");
|
||||
});
|
||||
|
||||
it("returns redacted storage-backed content without storage ids", async () => {
|
||||
vi.stubEnv("SECURITY_SCAN_WORKER_TOKEN", "worker-secret");
|
||||
const result = await listArtifactExportBatchCompressedHandler(makeCtx(), {
|
||||
token: "worker-secret",
|
||||
sourceKind: "skill",
|
||||
paginationOpts: { cursor: null, numItems: 1 },
|
||||
pageCount: 1,
|
||||
});
|
||||
|
||||
const decoded = JSON.parse(
|
||||
gunzipSync(Buffer.from(result.payload, "base64")).toString("utf8"),
|
||||
) as {
|
||||
page: Array<{
|
||||
skillMdContentRedacted: string;
|
||||
bundleFilesRedacted: Array<{ path: string; content: string }>;
|
||||
files: Array<Record<string, unknown>>;
|
||||
}>;
|
||||
};
|
||||
|
||||
expect(decoded.page).toEqual([
|
||||
expect.objectContaining({
|
||||
skillMdContentRedacted: "Use this skill. [REDACTED_SECRET]",
|
||||
bundleFilesRedacted: [
|
||||
{
|
||||
path: "scripts/run.sh",
|
||||
content: "echo [REDACTED_SECRET]\n",
|
||||
},
|
||||
],
|
||||
files: [
|
||||
expect.not.objectContaining({ storageId: expect.anything() }),
|
||||
expect.not.objectContaining({ storageId: expect.anything() }),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function makeCtx() {
|
||||
return {
|
||||
runQuery: vi.fn(async () => ({
|
||||
page: [
|
||||
{
|
||||
sourceKind: "skill",
|
||||
sourceDocId: "skillVersions:1",
|
||||
parentDocId: "skills:1",
|
||||
publicName: "Demo",
|
||||
publicOwnerHandle: "owner",
|
||||
publicSlug: "demo",
|
||||
version: "1.0.0",
|
||||
artifactSha256: "a".repeat(64),
|
||||
createdAt: 1,
|
||||
softDeletedAt: null,
|
||||
files: [
|
||||
{
|
||||
path: "SKILL.md",
|
||||
size: 42,
|
||||
sha256: "skill-sha",
|
||||
storageId: "storage:skill",
|
||||
contentType: "text/markdown",
|
||||
},
|
||||
{
|
||||
path: "scripts/run.sh",
|
||||
size: 32,
|
||||
sha256: "script-sha",
|
||||
storageId: "storage:script",
|
||||
contentType: "text/x-shellscript",
|
||||
},
|
||||
],
|
||||
packageFamily: null,
|
||||
packageChannel: null,
|
||||
sourceRepoHost: null,
|
||||
vtAnalysis: null,
|
||||
skillSpectorAnalysis: null,
|
||||
staticScan: null,
|
||||
llmAnalysis: null,
|
||||
moderationConsensus: null,
|
||||
},
|
||||
],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
exportMode: "public",
|
||||
})),
|
||||
storage: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "storage:skill") {
|
||||
return new Blob(["Use this skill. token=supersecret123"]);
|
||||
}
|
||||
if (id === "storage:script") {
|
||||
return new Blob(["echo password=scriptsecret123\n"]);
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
+102
-35
@@ -2,10 +2,10 @@
|
||||
|
||||
import { gzipSync } from "node:zlib";
|
||||
import { paginationOptsValidator } from "convex/server";
|
||||
import { v } from "convex/values";
|
||||
import { ConvexError, v } from "convex/values";
|
||||
import { internal } from "./_generated/api";
|
||||
import type { ActionCtx } from "./_generated/server";
|
||||
import { internalAction } from "./functions";
|
||||
import { action, internalAction } from "./functions";
|
||||
|
||||
const MAX_EXPORT_BATCH_PAGES = 20;
|
||||
const MAX_REDACTED_BUNDLE_FILE_BYTES = 192 * 1024;
|
||||
@@ -19,6 +19,21 @@ type ArtifactExportPage = {
|
||||
exportMode: "public";
|
||||
};
|
||||
|
||||
type DatasetLineage = {
|
||||
exportMode: "public";
|
||||
generatedAt: number;
|
||||
maxExportPageSize: number;
|
||||
maxExportBatchPages: number;
|
||||
redactionPolicyVersion: string;
|
||||
sourceTables: readonly string[];
|
||||
scannerSources: readonly string[];
|
||||
sourceBounds: Array<{
|
||||
sourceKind: "skill" | "package";
|
||||
minCreatedAt: number | null;
|
||||
maxCreatedAt: number | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
const SECRET_PATTERNS: RegExp[] = [
|
||||
/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi,
|
||||
/\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g,
|
||||
@@ -31,6 +46,24 @@ const SECRET_PATTERNS: RegExp[] = [
|
||||
/(["'`])(?=[A-Za-z0-9+/=_-]{32,}\1)(?=.*[A-Z])(?=.*[a-z])(?=.*\d)[A-Za-z0-9+/=_-]+\1/g,
|
||||
];
|
||||
|
||||
type ArtifactExportBatchArgs = {
|
||||
sourceKind: "skill" | "package";
|
||||
mode?: "public";
|
||||
createdAtGte?: number;
|
||||
createdAtLt?: number;
|
||||
paginationOpts: {
|
||||
cursor: string | null;
|
||||
numItems: number;
|
||||
};
|
||||
pageCount: number;
|
||||
};
|
||||
|
||||
function assertDatasetExportWorkerToken(token: string) {
|
||||
// Shared worker credential already used by the security and Skill Card GitHub workers.
|
||||
const expected = process.env.SECURITY_SCAN_WORKER_TOKEN;
|
||||
if (!expected || token !== expected) throw new ConvexError("Unauthorized");
|
||||
}
|
||||
|
||||
export const listArtifactExportBatchCompressedInternal = internalAction({
|
||||
args: {
|
||||
sourceKind: v.union(v.literal("skill"), v.literal("package")),
|
||||
@@ -40,43 +73,77 @@ export const listArtifactExportBatchCompressedInternal = internalAction({
|
||||
paginationOpts: paginationOptsValidator,
|
||||
pageCount: v.number(),
|
||||
},
|
||||
handler: async (ctx, args) => listArtifactExportBatchCompressedForWorker(ctx, args),
|
||||
});
|
||||
|
||||
export const listArtifactExportBatchCompressed = action({
|
||||
args: {
|
||||
token: v.string(),
|
||||
sourceKind: v.union(v.literal("skill"), v.literal("package")),
|
||||
mode: v.optional(v.literal("public")),
|
||||
createdAtGte: v.optional(v.number()),
|
||||
createdAtLt: v.optional(v.number()),
|
||||
paginationOpts: paginationOptsValidator,
|
||||
pageCount: v.number(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const pageCount = Math.min(Math.max(1, Math.floor(args.pageCount)), MAX_EXPORT_BATCH_PAGES);
|
||||
let cursor = args.paginationOpts.cursor;
|
||||
const page: ArtifactExportPage["page"] = [];
|
||||
let isDone = false;
|
||||
for (let pageIndex = 0; pageIndex < pageCount; pageIndex += 1) {
|
||||
const result: ArtifactExportPage = await ctx.runQuery(
|
||||
internal.securityDataset.listArtifactExportPageInternal,
|
||||
{
|
||||
sourceKind: args.sourceKind,
|
||||
mode: args.mode,
|
||||
createdAtGte: args.createdAtGte,
|
||||
createdAtLt: args.createdAtLt,
|
||||
paginationOpts: {
|
||||
cursor,
|
||||
numItems: args.paginationOpts.numItems,
|
||||
},
|
||||
},
|
||||
);
|
||||
page.push(...result.page);
|
||||
cursor = result.continueCursor;
|
||||
isDone = result.isDone;
|
||||
if (isDone) break;
|
||||
}
|
||||
const json = JSON.stringify({
|
||||
page: await enrichAndSanitizeArtifactRows(ctx, page),
|
||||
isDone,
|
||||
continueCursor: cursor,
|
||||
exportMode: args.mode ?? "public",
|
||||
});
|
||||
return {
|
||||
encoding: "gzip-base64-json" as const,
|
||||
payload: gzipSync(json).toString("base64"),
|
||||
};
|
||||
assertDatasetExportWorkerToken(args.token);
|
||||
return await listArtifactExportBatchCompressedForWorker(ctx, args);
|
||||
},
|
||||
});
|
||||
|
||||
export const getDatasetLineage = action({
|
||||
args: {
|
||||
token: v.string(),
|
||||
mode: v.optional(v.literal("public")),
|
||||
},
|
||||
handler: async (ctx, args): Promise<DatasetLineage> => {
|
||||
assertDatasetExportWorkerToken(args.token);
|
||||
return await ctx.runQuery(internal.securityDataset.getDatasetLineageInternal, {
|
||||
mode: args.mode,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
async function listArtifactExportBatchCompressedForWorker(
|
||||
ctx: ActionCtx,
|
||||
args: ArtifactExportBatchArgs,
|
||||
) {
|
||||
const pageCount = Math.min(Math.max(1, Math.floor(args.pageCount)), MAX_EXPORT_BATCH_PAGES);
|
||||
let cursor = args.paginationOpts.cursor;
|
||||
const page: ArtifactExportPage["page"] = [];
|
||||
let isDone = false;
|
||||
for (let pageIndex = 0; pageIndex < pageCount; pageIndex += 1) {
|
||||
const result: ArtifactExportPage = await ctx.runQuery(
|
||||
internal.securityDataset.listArtifactExportPageInternal,
|
||||
{
|
||||
sourceKind: args.sourceKind,
|
||||
mode: args.mode,
|
||||
createdAtGte: args.createdAtGte,
|
||||
createdAtLt: args.createdAtLt,
|
||||
paginationOpts: {
|
||||
cursor,
|
||||
numItems: args.paginationOpts.numItems,
|
||||
},
|
||||
},
|
||||
);
|
||||
page.push(...result.page);
|
||||
cursor = result.continueCursor;
|
||||
isDone = result.isDone;
|
||||
if (isDone) break;
|
||||
}
|
||||
const json = JSON.stringify({
|
||||
page: await enrichAndSanitizeArtifactRows(ctx, page),
|
||||
isDone,
|
||||
continueCursor: cursor,
|
||||
exportMode: args.mode ?? "public",
|
||||
});
|
||||
return {
|
||||
encoding: "gzip-base64-json" as const,
|
||||
payload: gzipSync(json).toString("base64"),
|
||||
};
|
||||
}
|
||||
|
||||
async function enrichAndSanitizeArtifactRows(ctx: ActionCtx, rows: unknown[]) {
|
||||
const enrichedRows = [];
|
||||
let remainingBundleBytes = MAX_REDACTED_BUNDLE_BYTES_PER_RESPONSE;
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import { execFile, spawnSync } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { createWriteStream, type WriteStream } from "node:fs";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readdir, stat, writeFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { gunzipSync } from "node:zlib";
|
||||
import { ConvexHttpClient } from "convex/browser";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import { artifactInputsFromConvexExportZip } from "./convexExport";
|
||||
import { parseConvexJsonMatching } from "./convexOutput";
|
||||
import { reserveExportInputs } from "./exportLimit";
|
||||
import {
|
||||
buildHuggingFaceSecuritySignalRows,
|
||||
type HuggingFaceSecuritySignalRow,
|
||||
} from "./huggingFaceExport";
|
||||
import { buildSecurityDatasetManifest } from "./manifest";
|
||||
import {
|
||||
normalizeArtifactExport,
|
||||
type ArtifactExportInput,
|
||||
type DatasetSplit,
|
||||
type NormalizedDatasetRows,
|
||||
type SourceKind,
|
||||
} from "./normalize";
|
||||
@@ -38,6 +45,14 @@ type ConvexBounds = {
|
||||
maxCreatedAt: number | null;
|
||||
};
|
||||
|
||||
type DatasetLineage = {
|
||||
exportMode: "public";
|
||||
generatedAt: number;
|
||||
redactionPolicyVersion: string;
|
||||
sourceTables: string[];
|
||||
sourceBounds: ConvexBounds[];
|
||||
};
|
||||
|
||||
type CompressedConvexPage = {
|
||||
encoding: "gzip-base64-json";
|
||||
payload: string;
|
||||
@@ -45,6 +60,8 @@ type CompressedConvexPage = {
|
||||
|
||||
type Options = {
|
||||
deployment: string | null;
|
||||
convexUrl: string | null;
|
||||
workerToken: string | null;
|
||||
prod: boolean;
|
||||
push: boolean;
|
||||
dryRun: boolean;
|
||||
@@ -58,6 +75,10 @@ type Options = {
|
||||
sourceKind: SourceKind | "all";
|
||||
timeWindow: CreatedTimeWindow;
|
||||
convexExportZip: string | null;
|
||||
sourceSnapshotId: string | null;
|
||||
huggingFaceDataset: boolean;
|
||||
huggingFaceRepo: string;
|
||||
huggingFaceRevision: string;
|
||||
};
|
||||
|
||||
type ExportShard = {
|
||||
@@ -76,7 +97,9 @@ type SnapshotState = {
|
||||
clawScanFindings: number;
|
||||
labels: number;
|
||||
splits: number;
|
||||
huggingFaceRows: number;
|
||||
};
|
||||
huggingFaceRowCountsBySplit: Record<DatasetSplit, number>;
|
||||
scannerVersions: Set<string>;
|
||||
modelNames: Set<string>;
|
||||
};
|
||||
@@ -88,6 +111,7 @@ type SnapshotWriters = {
|
||||
clawScanFindings: WriteStream;
|
||||
labels: WriteStream;
|
||||
splits: WriteStream;
|
||||
huggingFaceSplits?: Record<DatasetSplit, WriteStream>;
|
||||
};
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 50;
|
||||
@@ -98,12 +122,13 @@ const DEFAULT_MAX_CONVEX_ATTEMPTS = 6;
|
||||
const DEFAULT_OUT_DIR = ".data/security-dataset/snapshots";
|
||||
const CONVEX_RUN_MAX_BUFFER_BYTES = 128 * 1024 * 1024;
|
||||
const SOURCE_KINDS: SourceKind[] = ["skill", "package"];
|
||||
const HF_SPLITS: DatasetSplit[] = ["train", "validation", "test", "eval_holdout"];
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const snapshotId = buildSnapshotId(options);
|
||||
const snapshotDir = resolve(options.outDir, snapshotId);
|
||||
const writers = options.dryRun ? null : await openSnapshotWriters(snapshotDir);
|
||||
const writers = options.dryRun ? null : await openSnapshotWriters(snapshotDir, options);
|
||||
let writersClosed = false;
|
||||
const state = createSnapshotState();
|
||||
|
||||
@@ -111,9 +136,15 @@ async function main() {
|
||||
const shardCount = options.convexExportZip
|
||||
? await exportConvexExportZip({ options, state, writers })
|
||||
: await exportRemoteShards({ options, state, writers });
|
||||
const manifest = buildManifest({ options, snapshotId, state, shardCount });
|
||||
|
||||
if (options.dryRun) {
|
||||
const manifest = buildManifest({
|
||||
options,
|
||||
snapshotId,
|
||||
state,
|
||||
shardCount,
|
||||
outputSizes: {},
|
||||
});
|
||||
console.log(JSON.stringify({ snapshotId, dryRun: true, manifest }, null, 2));
|
||||
return;
|
||||
}
|
||||
@@ -121,6 +152,8 @@ async function main() {
|
||||
if (!writers) throw new Error("Snapshot writers were not opened.");
|
||||
await closeSnapshotWriters(writers);
|
||||
writersClosed = true;
|
||||
const outputSizes = await collectOutputSizes(snapshotDir);
|
||||
const manifest = buildManifest({ options, snapshotId, state, shardCount, outputSizes });
|
||||
await writeFile(join(snapshotDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
|
||||
console.log(JSON.stringify({ snapshotId, snapshotDir, manifest }, null, 2));
|
||||
@@ -231,6 +264,7 @@ async function runConvexPage(
|
||||
batchPages: number,
|
||||
): Promise<{ page: ConvexPage; batchPages: number }> {
|
||||
const functionName = "securityDatasetNode:listArtifactExportBatchCompressedInternal";
|
||||
const workerFunctionName = "securityDatasetNode:listArtifactExportBatchCompressed";
|
||||
let pageCount = batchPages;
|
||||
|
||||
while (true) {
|
||||
@@ -246,12 +280,19 @@ async function runConvexPage(
|
||||
let lastError: unknown = null;
|
||||
for (let attempt = 1; attempt <= DEFAULT_MAX_CONVEX_ATTEMPTS; attempt += 1) {
|
||||
try {
|
||||
const compressed = await runConvexJsonOnce<CompressedConvexPage>(
|
||||
options,
|
||||
functionName,
|
||||
args,
|
||||
isCompressedConvexPage,
|
||||
);
|
||||
const compressed = options.workerToken
|
||||
? await runWorkerAction<CompressedConvexPage>(
|
||||
options,
|
||||
workerFunctionName,
|
||||
{ ...args, token: options.workerToken },
|
||||
isCompressedConvexPage,
|
||||
)
|
||||
: await runConvexJsonOnce<CompressedConvexPage>(
|
||||
options,
|
||||
functionName,
|
||||
args,
|
||||
isCompressedConvexPage,
|
||||
);
|
||||
return { page: decodeCompressedConvexPage(compressed), batchPages: pageCount };
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
@@ -279,6 +320,19 @@ async function runConvexPage(
|
||||
}
|
||||
|
||||
async function runConvexBounds(options: Options, sourceKind: SourceKind): Promise<ConvexBounds> {
|
||||
if (options.workerToken) {
|
||||
const lineage = await runWorkerAction<DatasetLineage>(
|
||||
options,
|
||||
"securityDatasetNode:getDatasetLineage",
|
||||
{ token: options.workerToken, mode: options.mode },
|
||||
isDatasetLineage,
|
||||
);
|
||||
const bounds = lineage.sourceBounds.find((candidate) => candidate.sourceKind === sourceKind);
|
||||
if (!bounds) {
|
||||
return { sourceKind, minCreatedAt: null, maxCreatedAt: null };
|
||||
}
|
||||
return bounds;
|
||||
}
|
||||
return runConvexJson<ConvexBounds>(
|
||||
options,
|
||||
"securityDataset:getArtifactExportBoundsInternal",
|
||||
@@ -287,6 +341,31 @@ async function runConvexBounds(options: Options, sourceKind: SourceKind): Promis
|
||||
);
|
||||
}
|
||||
|
||||
async function runWorkerAction<T>(
|
||||
options: Options,
|
||||
functionName: string,
|
||||
args: unknown,
|
||||
validate: (value: unknown) => value is T,
|
||||
): Promise<T> {
|
||||
if (!options.convexUrl) {
|
||||
throw new Error("--convex-url or CONVEX_URL is required when using --worker-token.");
|
||||
}
|
||||
const client = new ConvexHttpClient(options.convexUrl);
|
||||
const result = await client.action(resolveWorkerAction(functionName), args as never);
|
||||
if (validate(result)) return result;
|
||||
throw new Error(`Invalid ${functionName} response.`);
|
||||
}
|
||||
|
||||
function resolveWorkerAction(functionName: string) {
|
||||
if (functionName === "securityDatasetNode:listArtifactExportBatchCompressed") {
|
||||
return api.securityDatasetNode.listArtifactExportBatchCompressed;
|
||||
}
|
||||
if (functionName === "securityDatasetNode:getDatasetLineage") {
|
||||
return api.securityDatasetNode.getDatasetLineage;
|
||||
}
|
||||
throw new Error(`Unsupported worker action: ${functionName}`);
|
||||
}
|
||||
|
||||
async function runConvexJson<T>(
|
||||
options: Options,
|
||||
functionName: string,
|
||||
@@ -352,14 +431,19 @@ function buildManifest(input: {
|
||||
snapshotId: string;
|
||||
state: SnapshotState;
|
||||
shardCount: number;
|
||||
outputSizes: Record<string, number>;
|
||||
}) {
|
||||
const { options, snapshotId, state, shardCount } = input;
|
||||
const { options, snapshotId, state, shardCount, outputSizes } = input;
|
||||
const repoGitSha = gitSha();
|
||||
return buildSecurityDatasetManifest({
|
||||
snapshotId,
|
||||
sourceSnapshotId: options.sourceSnapshotId ?? snapshotId,
|
||||
createdAt: new Date().toISOString(),
|
||||
repoGitSha,
|
||||
convexDeployment: options.deployment ?? (options.prod ? "prod" : "configured-dev"),
|
||||
convexDeployment:
|
||||
options.deployment ??
|
||||
inferDeploymentFromConvexUrl(options.convexUrl) ??
|
||||
(options.prod ? "prod" : "configured-dev"),
|
||||
exportMode: options.mode,
|
||||
pageSize: options.pageSize,
|
||||
concurrency: options.concurrency,
|
||||
@@ -373,22 +457,45 @@ function buildManifest(input: {
|
||||
clawScanFindings: state.rowCounts.clawScanFindings,
|
||||
labels: state.rowCounts.labels,
|
||||
splits: state.rowCounts.splits,
|
||||
huggingFaceRows: state.rowCounts.huggingFaceRows,
|
||||
},
|
||||
outputSizes,
|
||||
scannerVersions: Array.from(state.scannerVersions).sort(),
|
||||
modelNames: Array.from(state.modelNames).sort(),
|
||||
redactionPolicyVersion: "public-signals-v2-bundle-files",
|
||||
sourceTables: ["skillVersions", "packageReleases"],
|
||||
timeWindow: options.timeWindow,
|
||||
huggingFaceDataset: options.huggingFaceDataset
|
||||
? {
|
||||
repo: options.huggingFaceRepo,
|
||||
revision: options.huggingFaceRevision,
|
||||
commit: null,
|
||||
configNames: ["default"],
|
||||
splitNames: HF_SPLITS,
|
||||
rowCountsBySplit: state.huggingFaceRowCountsBySplit,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function inferDeploymentFromConvexUrl(convexUrl: string | null) {
|
||||
if (!convexUrl) return null;
|
||||
try {
|
||||
return new URL(convexUrl).hostname.split(".")[0] || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildSnapshotId(options: Options) {
|
||||
const timestamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[-:]/g, "")
|
||||
.replace(/\.\d{3}Z$/, "Z");
|
||||
const deployment =
|
||||
options.deployment?.replace(/[^a-zA-Z0-9]+/g, "-") ?? (options.prod ? "prod" : "dev");
|
||||
options.deployment?.replace(/[^a-zA-Z0-9]+/g, "-") ??
|
||||
inferDeploymentFromConvexUrl(options.convexUrl)?.replace(/[^a-zA-Z0-9]+/g, "-") ??
|
||||
(options.prod ? "prod" : "dev");
|
||||
return `clawhub-${deployment}-${timestamp}-${gitSha().slice(0, 8)}`;
|
||||
}
|
||||
|
||||
@@ -402,6 +509,13 @@ function createSnapshotState(): SnapshotState {
|
||||
clawScanFindings: 0,
|
||||
labels: 0,
|
||||
splits: 0,
|
||||
huggingFaceRows: 0,
|
||||
},
|
||||
huggingFaceRowCountsBySplit: {
|
||||
train: 0,
|
||||
validation: 0,
|
||||
test: 0,
|
||||
eval_holdout: 0,
|
||||
},
|
||||
scannerVersions: new Set(),
|
||||
modelNames: new Set(),
|
||||
@@ -415,12 +529,17 @@ async function processArtifactInputs(input: {
|
||||
}) {
|
||||
const { inputs, state, writers } = input;
|
||||
const rows = normalizeArtifactExport(inputs);
|
||||
const hfRows = buildHuggingFaceSecuritySignalRows(rows);
|
||||
state.rowCounts.artifacts += rows.artifacts.length;
|
||||
state.rowCounts.scanResults += rows.scanResults.length;
|
||||
state.rowCounts.staticFindings += rows.staticFindings.length;
|
||||
state.rowCounts.clawScanFindings += rows.clawScanFindings.length;
|
||||
state.rowCounts.labels += rows.labels.length;
|
||||
state.rowCounts.splits += rows.splits.length;
|
||||
state.rowCounts.huggingFaceRows += hfRows.length;
|
||||
for (const row of hfRows) {
|
||||
state.huggingFaceRowCountsBySplit[row.split] += 1;
|
||||
}
|
||||
for (const row of rows.scanResults) {
|
||||
if (row.scanner_version) state.scannerVersions.add(row.scanner_version);
|
||||
if (row.model) state.modelNames.add(row.model);
|
||||
@@ -428,11 +547,17 @@ async function processArtifactInputs(input: {
|
||||
|
||||
if (!writers) return;
|
||||
await writeNormalizedRows(writers, rows);
|
||||
if (writers.huggingFaceSplits) {
|
||||
await writeHuggingFaceRows(writers.huggingFaceSplits, hfRows);
|
||||
}
|
||||
}
|
||||
|
||||
async function openSnapshotWriters(snapshotDir: string): Promise<SnapshotWriters> {
|
||||
async function openSnapshotWriters(
|
||||
snapshotDir: string,
|
||||
options: Options,
|
||||
): Promise<SnapshotWriters> {
|
||||
await mkdir(snapshotDir, { recursive: true });
|
||||
return {
|
||||
const writers: SnapshotWriters = {
|
||||
artifacts: createWriteStream(join(snapshotDir, "artifacts.jsonl"), { encoding: "utf8" }),
|
||||
scanResults: createWriteStream(join(snapshotDir, "scan_results.jsonl"), { encoding: "utf8" }),
|
||||
staticFindings: createWriteStream(join(snapshotDir, "static_findings.jsonl"), {
|
||||
@@ -444,10 +569,30 @@ async function openSnapshotWriters(snapshotDir: string): Promise<SnapshotWriters
|
||||
labels: createWriteStream(join(snapshotDir, "labels.jsonl"), { encoding: "utf8" }),
|
||||
splits: createWriteStream(join(snapshotDir, "splits.jsonl"), { encoding: "utf8" }),
|
||||
};
|
||||
if (options.huggingFaceDataset) {
|
||||
const dataDir = join(snapshotDir, "hf-dataset", "data");
|
||||
await mkdir(dataDir, { recursive: true });
|
||||
writers.huggingFaceSplits = {
|
||||
train: createWriteStream(join(dataDir, "train.jsonl"), { encoding: "utf8" }),
|
||||
validation: createWriteStream(join(dataDir, "validation.jsonl"), { encoding: "utf8" }),
|
||||
test: createWriteStream(join(dataDir, "test.jsonl"), { encoding: "utf8" }),
|
||||
eval_holdout: createWriteStream(join(dataDir, "eval_holdout.jsonl"), { encoding: "utf8" }),
|
||||
};
|
||||
}
|
||||
return writers;
|
||||
}
|
||||
|
||||
async function closeSnapshotWriters(writers: SnapshotWriters) {
|
||||
await Promise.all(Object.values(writers).map((stream) => endStream(stream)));
|
||||
const streams = [
|
||||
writers.artifacts,
|
||||
writers.scanResults,
|
||||
writers.staticFindings,
|
||||
writers.clawScanFindings,
|
||||
writers.labels,
|
||||
writers.splits,
|
||||
...Object.values(writers.huggingFaceSplits ?? {}),
|
||||
];
|
||||
await Promise.all(streams.map((stream) => endStream(stream)));
|
||||
}
|
||||
|
||||
async function endStream(stream: WriteStream) {
|
||||
@@ -470,6 +615,37 @@ async function writeJsonlRows(stream: WriteStream, rows: unknown[]) {
|
||||
if (!stream.write(chunk)) await once(stream, "drain");
|
||||
}
|
||||
|
||||
async function writeHuggingFaceRows(
|
||||
streams: Record<DatasetSplit, WriteStream>,
|
||||
rows: HuggingFaceSecuritySignalRow[],
|
||||
) {
|
||||
for (const split of HF_SPLITS) {
|
||||
await writeJsonlRows(
|
||||
streams[split],
|
||||
rows.filter((row) => row.split === split),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function collectOutputSizes(root: string) {
|
||||
const sizes: Record<string, number> = {};
|
||||
await collectOutputSizesInto(root, root, sizes);
|
||||
return sizes;
|
||||
}
|
||||
|
||||
async function collectOutputSizesInto(root: string, dir: string, sizes: Record<string, number>) {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await collectOutputSizesInto(root, path, sizes);
|
||||
} else if (entry.isFile()) {
|
||||
const relativePath = path.slice(root.length + 1);
|
||||
sizes[relativePath] = (await stat(path)).size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function boundsToShards(bounds: ConvexBounds, shardCount: number): ExportShard[] {
|
||||
if (bounds.minCreatedAt === null || bounds.maxCreatedAt === null) return [];
|
||||
const start = bounds.minCreatedAt;
|
||||
@@ -547,6 +723,18 @@ function isCompressedConvexPage(value: unknown): value is CompressedConvexPage {
|
||||
);
|
||||
}
|
||||
|
||||
function isDatasetLineage(value: unknown): value is DatasetLineage {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
value.exportMode === "public" &&
|
||||
typeof value.generatedAt === "number" &&
|
||||
typeof value.redactionPolicyVersion === "string" &&
|
||||
Array.isArray(value.sourceTables) &&
|
||||
Array.isArray(value.sourceBounds) &&
|
||||
value.sourceBounds.every(isConvexBounds)
|
||||
);
|
||||
}
|
||||
|
||||
function decodeCompressedConvexPage(value: CompressedConvexPage) {
|
||||
const json = gunzipSync(Buffer.from(value.payload, "base64")).toString("utf8");
|
||||
const parsed: unknown = JSON.parse(json);
|
||||
@@ -588,6 +776,8 @@ function gitSha() {
|
||||
function parseArgs(args: string[]): Options {
|
||||
const options: Options = {
|
||||
deployment: null,
|
||||
convexUrl: null,
|
||||
workerToken: null,
|
||||
prod: false,
|
||||
push: false,
|
||||
dryRun: false,
|
||||
@@ -601,6 +791,10 @@ function parseArgs(args: string[]): Options {
|
||||
sourceKind: "all",
|
||||
timeWindow: emptyCreatedTimeWindow(),
|
||||
convexExportZip: null,
|
||||
sourceSnapshotId: null,
|
||||
huggingFaceDataset: false,
|
||||
huggingFaceRepo: process.env.HF_DATASET_REPO ?? "OpenClaw/clawhub-security-signals",
|
||||
huggingFaceRevision: process.env.HF_REVISION ?? "main",
|
||||
};
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
@@ -613,6 +807,10 @@ function parseArgs(args: string[]): Options {
|
||||
options.dryRun = true;
|
||||
} else if (arg === "--deployment") {
|
||||
options.deployment = readValue(args, ++index, arg);
|
||||
} else if (arg === "--convex-url") {
|
||||
options.convexUrl = readValue(args, ++index, arg);
|
||||
} else if (arg === "--worker-token") {
|
||||
options.workerToken = readValue(args, ++index, arg);
|
||||
} else if (arg === "--limit") {
|
||||
options.limit = readPositiveInt(readValue(args, ++index, arg), arg);
|
||||
} else if (arg === "--page-size") {
|
||||
@@ -633,6 +831,14 @@ function parseArgs(args: string[]): Options {
|
||||
options.timeWindow.createdAtLt = parseCreatedTimestamp(readValue(args, ++index, arg), arg);
|
||||
} else if (arg === "--convex-export-zip" || arg === "--from-convex-export") {
|
||||
options.convexExportZip = readValue(args, ++index, arg);
|
||||
} else if (arg === "--source-snapshot-id") {
|
||||
options.sourceSnapshotId = readValue(args, ++index, arg);
|
||||
} else if (arg === "--hf-dataset") {
|
||||
options.huggingFaceDataset = true;
|
||||
} else if (arg === "--hf-repo") {
|
||||
options.huggingFaceRepo = readValue(args, ++index, arg);
|
||||
} else if (arg === "--hf-revision") {
|
||||
options.huggingFaceRevision = readValue(args, ++index, arg);
|
||||
} else if (arg === "--mode") {
|
||||
const mode = readValue(args, ++index, arg);
|
||||
if (mode !== "public") throw new Error(`Unsupported mode: ${mode}`);
|
||||
@@ -645,6 +851,9 @@ function parseArgs(args: string[]): Options {
|
||||
if (options.prod && options.deployment) {
|
||||
throw new Error("Use either --prod or --deployment, not both.");
|
||||
}
|
||||
if (options.workerToken && (options.prod || options.deployment || options.push)) {
|
||||
throw new Error("Use --worker-token with --convex-url instead of --prod/--deployment/--push.");
|
||||
}
|
||||
assertCreatedTimeWindow(options.timeWindow);
|
||||
return options;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/* @vitest-environment node */
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { strToU8, zipSync } from "fflate";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
describe("security dataset snapshot CLI", () => {
|
||||
it("writes flat Hugging Face split files from a tiny sanitized Convex export", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "clawhub-security-dataset-cli-"));
|
||||
try {
|
||||
const snapshotZip = join(directory, "snapshot.zip");
|
||||
const outDir = join(directory, "out");
|
||||
await writeFile(snapshotZip, Buffer.from(buildTinyConvexSnapshotZip()));
|
||||
|
||||
const result = await execFileAsync(
|
||||
"bun",
|
||||
[
|
||||
"scripts/security-dataset/export-snapshot.ts",
|
||||
"--convex-export-zip",
|
||||
snapshotZip,
|
||||
"--source-snapshot-id",
|
||||
"live-export-prod-123-1",
|
||||
"--out-dir",
|
||||
outDir,
|
||||
"--hf-dataset",
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
const summary: {
|
||||
snapshotDir: string;
|
||||
manifest: {
|
||||
source_snapshot_id: string;
|
||||
row_counts: { huggingface_rows: number };
|
||||
huggingface_dataset: { repo: string; rowCountsBySplit: Record<string, number> };
|
||||
};
|
||||
} = JSON.parse(result.stdout);
|
||||
|
||||
expect(summary.manifest.source_snapshot_id).toBe("live-export-prod-123-1");
|
||||
expect(summary.manifest.row_counts.huggingface_rows).toBe(1);
|
||||
expect(summary.manifest.huggingface_dataset.repo).toBe("OpenClaw/clawhub-security-signals");
|
||||
const dataDir = join(summary.snapshotDir, "hf-dataset", "data");
|
||||
const files = await readdir(dataDir);
|
||||
expect(files.sort()).toEqual([
|
||||
"eval_holdout.jsonl",
|
||||
"test.jsonl",
|
||||
"train.jsonl",
|
||||
"validation.jsonl",
|
||||
]);
|
||||
|
||||
const splitContents = await Promise.all(
|
||||
files.map(async (file) => readFile(join(dataDir, file), "utf8")),
|
||||
);
|
||||
const flatRows = splitContents
|
||||
.join("")
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.length > 0)
|
||||
.map((line) => JSON.parse(line) as Record<string, unknown>);
|
||||
|
||||
expect(flatRows).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "a".repeat(64),
|
||||
skill_slug: "owner/stored-skill",
|
||||
skill_version: "1.0.0",
|
||||
skill_md_content: "Stored SKILL.md [REDACTED_SECRET]",
|
||||
skill_bundle_content: [
|
||||
expect.objectContaining({
|
||||
path: "scripts/run.sh",
|
||||
content: "echo [REDACTED_SECRET]\n",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
const serializedRows = JSON.stringify(flatRows);
|
||||
expect(serializedRows).not.toContain("storageId");
|
||||
expect(serializedRows).not.toContain("skillVersions:1");
|
||||
expect(serializedRows).not.toContain("supersecret123");
|
||||
expect(serializedRows).not.toContain("scriptsecret123");
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function buildTinyConvexSnapshotZip() {
|
||||
return zipSync({
|
||||
"skills/documents.jsonl": strToU8(
|
||||
`${JSON.stringify({
|
||||
_id: "skills:1",
|
||||
displayName: "Stored Skill",
|
||||
slug: "stored-skill",
|
||||
ownerUserId: "users:owner",
|
||||
})}\n`,
|
||||
),
|
||||
"skillVersions/documents.jsonl": strToU8(
|
||||
`${JSON.stringify({
|
||||
_id: "skillVersions:1",
|
||||
skillId: "skills:1",
|
||||
version: "1.0.0",
|
||||
createdAt: Date.UTC(2026, 5, 23),
|
||||
sha256hash: "a".repeat(64),
|
||||
files: [
|
||||
{
|
||||
path: "SKILL.md",
|
||||
size: 31,
|
||||
sha256: "skill-md-sha",
|
||||
content: "Stored SKILL.md token=supersecret123",
|
||||
contentType: "text/markdown",
|
||||
},
|
||||
{
|
||||
path: "scripts/run.sh",
|
||||
size: 35,
|
||||
sha256: "script-sha",
|
||||
content: "echo password=scriptsecret123\n",
|
||||
contentType: "text/x-shellscript",
|
||||
},
|
||||
],
|
||||
staticScan: {
|
||||
status: "clean",
|
||||
reasonCodes: [],
|
||||
findings: [],
|
||||
summary: "No suspicious patterns detected.",
|
||||
engineVersion: "static-v1",
|
||||
checkedAt: Date.UTC(2026, 5, 23),
|
||||
},
|
||||
llmAnalysis: {
|
||||
status: "completed",
|
||||
verdict: "suspicious",
|
||||
confidence: "medium",
|
||||
summary: "Review before trusting.",
|
||||
agenticRiskFindings: [],
|
||||
model: "gpt-test",
|
||||
checkedAt: Date.UTC(2026, 5, 23),
|
||||
},
|
||||
})}\n`,
|
||||
),
|
||||
"packages/documents.jsonl": strToU8(""),
|
||||
"packageReleases/documents.jsonl": strToU8(""),
|
||||
"users/documents.jsonl": strToU8(
|
||||
`${JSON.stringify({ _id: "users:owner", handle: "owner" })}\n`,
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildHuggingFaceSecuritySignalRows } from "./huggingFaceExport";
|
||||
import { hashString, type NormalizedDatasetRows } from "./normalize";
|
||||
|
||||
describe("Hugging Face security dataset export", () => {
|
||||
it("builds flat researcher rows from normalized security dataset sidecars", () => {
|
||||
const artifactId = `skill:${"a".repeat(64)}`;
|
||||
const normalized: NormalizedDatasetRows = {
|
||||
artifacts: [
|
||||
{
|
||||
artifact_id: artifactId,
|
||||
source_kind: "skill",
|
||||
source_table: "skillVersions",
|
||||
source_doc_id_hash: hashString("skillVersions:1"),
|
||||
parent_doc_id_hash: hashString("skills:1"),
|
||||
public_name: "Demo Skill",
|
||||
public_owner_handle: "openclaw",
|
||||
public_slug: "demo-skill",
|
||||
public_qualified_slug: "openclaw/demo-skill",
|
||||
version: "1.0.0",
|
||||
artifact_sha256: "a".repeat(64),
|
||||
skill_md_content_redacted: "Use this skill with [REDACTED_SECRET]",
|
||||
bundle_files_redacted: [
|
||||
{
|
||||
path: "scripts/run.sh",
|
||||
content: "echo [REDACTED_SECRET]\n",
|
||||
sha256: "bundle-content-sha",
|
||||
size_bytes: 23,
|
||||
},
|
||||
],
|
||||
created_at: Date.UTC(2026, 5, 23),
|
||||
created_month: "2026-06",
|
||||
soft_deleted: false,
|
||||
is_public: true,
|
||||
file_count: 2,
|
||||
total_bytes: 42,
|
||||
file_ext_counts: { ".md": 1, ".sh": 1 },
|
||||
package_family: null,
|
||||
package_channel: null,
|
||||
source_repo_host: null,
|
||||
has_vt_scan: true,
|
||||
has_skillspector_scan: true,
|
||||
has_static_scan: true,
|
||||
has_llm_scan: true,
|
||||
},
|
||||
],
|
||||
scanResults: [
|
||||
{
|
||||
artifact_id: artifactId,
|
||||
scanner: "static",
|
||||
scanner_version: "static-v1",
|
||||
model: null,
|
||||
status: "clean",
|
||||
verdict: "clean",
|
||||
confidence: null,
|
||||
checked_at: Date.UTC(2026, 5, 23),
|
||||
reason_codes: [],
|
||||
engine_stats: null,
|
||||
summary_redacted: "No suspicious patterns detected.",
|
||||
raw_status_family: "clean",
|
||||
},
|
||||
{
|
||||
artifact_id: artifactId,
|
||||
scanner: "virustotal",
|
||||
scanner_version: "vt-v3",
|
||||
model: null,
|
||||
status: "clean",
|
||||
verdict: "clean",
|
||||
confidence: null,
|
||||
checked_at: Date.UTC(2026, 5, 23),
|
||||
reason_codes: [],
|
||||
engine_stats: { malicious: 0, suspicious: 0, harmless: 1, undetected: 65 },
|
||||
summary_redacted: null,
|
||||
raw_status_family: "clean",
|
||||
},
|
||||
{
|
||||
artifact_id: artifactId,
|
||||
scanner: "skillspector",
|
||||
scanner_version: "2.0.0",
|
||||
model: null,
|
||||
status: "suspicious",
|
||||
verdict: "CAUTION",
|
||||
confidence: null,
|
||||
checked_at: Date.UTC(2026, 5, 23),
|
||||
score: 35,
|
||||
severity: "MEDIUM",
|
||||
reason_codes: ["SQP-1"],
|
||||
issues: [
|
||||
{
|
||||
code: "SQP-1",
|
||||
category: "Skill Quality",
|
||||
severity: "MEDIUM",
|
||||
confidence: 0.9,
|
||||
explanation_redacted: "Broad trigger scope.",
|
||||
},
|
||||
],
|
||||
engine_stats: null,
|
||||
summary_redacted: "Found broad trigger scope.",
|
||||
raw_status_family: "suspicious",
|
||||
},
|
||||
{
|
||||
artifact_id: artifactId,
|
||||
scanner: "llm",
|
||||
scanner_version: null,
|
||||
model: "gpt-test",
|
||||
status: "completed",
|
||||
verdict: "suspicious",
|
||||
confidence: "medium",
|
||||
checked_at: Date.UTC(2026, 5, 23),
|
||||
reason_codes: [],
|
||||
engine_stats: null,
|
||||
summary_redacted: "Review before trusting.",
|
||||
raw_status_family: "suspicious",
|
||||
},
|
||||
],
|
||||
staticFindings: [
|
||||
{
|
||||
artifact_id: artifactId,
|
||||
finding_id: "finding-1",
|
||||
code: "suspicious.env_credential_access",
|
||||
severity: "warn",
|
||||
file_path_hash: hashString("scripts/run.sh"),
|
||||
file_ext: ".sh",
|
||||
line_bucket: "1-20",
|
||||
message: "Reads credentials",
|
||||
evidence_redacted: "[REDACTED_SECRET]",
|
||||
},
|
||||
],
|
||||
clawScanFindings: [],
|
||||
labels: [
|
||||
{
|
||||
artifact_id: artifactId,
|
||||
label: "suspicious",
|
||||
label_source: "moderation_consensus",
|
||||
label_confidence: "derived_consensus",
|
||||
reason_codes: ["suspicious.env_credential_access"],
|
||||
scanner_agreement: 1,
|
||||
notes_redacted: null,
|
||||
},
|
||||
],
|
||||
splits: [
|
||||
{
|
||||
artifact_id: artifactId,
|
||||
split: "train",
|
||||
split_version: "sha256-v1",
|
||||
split_key: hashString("a".repeat(64)),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(buildHuggingFaceSecuritySignalRows(normalized)).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "a".repeat(64),
|
||||
skill_slug: "openclaw/demo-skill",
|
||||
skill_version: "1.0.0",
|
||||
skill_md_content: "Use this skill with [REDACTED_SECRET]",
|
||||
skill_bundle_content: [
|
||||
{
|
||||
path: "scripts/run.sh",
|
||||
content: "echo [REDACTED_SECRET]\n",
|
||||
sha256: "bundle-content-sha",
|
||||
sizeBytes: 23,
|
||||
},
|
||||
],
|
||||
clawscan_verdict: "suspicious",
|
||||
clawscan_confidence: "medium",
|
||||
clawscan_model: "gpt-test",
|
||||
static_status: "clean",
|
||||
static_finding_count: 1,
|
||||
virustotal_malicious_count: 0,
|
||||
skillspector_issue_categories: ["Skill Quality"],
|
||||
split: "train",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import {
|
||||
hashString,
|
||||
type ArtifactRow,
|
||||
type DatasetSplit,
|
||||
type LabelRow,
|
||||
type NormalizedDatasetRows,
|
||||
type ScanResultRow,
|
||||
type StaticFindingRow,
|
||||
} from "./normalize";
|
||||
|
||||
export type HuggingFaceSecuritySignalRow = {
|
||||
id: string;
|
||||
skill_slug: string | null;
|
||||
skill_version: string;
|
||||
skill_md_content: string | null;
|
||||
skill_bundle_content: Array<{
|
||||
path: string;
|
||||
content: string;
|
||||
sha256: string;
|
||||
sizeBytes: number;
|
||||
}>;
|
||||
clawscan_verdict: string;
|
||||
clawscan_confidence: string | null;
|
||||
clawscan_model: string | null;
|
||||
clawscan_summary: string | null;
|
||||
static_status: string | null;
|
||||
static_finding_count: number;
|
||||
static_reason_codes: string[];
|
||||
virustotal_status: string | null;
|
||||
virustotal_malicious_count: number | null;
|
||||
virustotal_suspicious_count: number | null;
|
||||
virustotal_harmless_count: number | null;
|
||||
virustotal_undetected_count: number | null;
|
||||
skillspector_status: string | null;
|
||||
skillspector_score: number | null;
|
||||
skillspector_severity: string | null;
|
||||
skillspector_issue_count: number;
|
||||
skillspector_issue_codes: string[];
|
||||
skillspector_issue_categories: string[];
|
||||
clawscan_context: Record<string, unknown>;
|
||||
split: DatasetSplit;
|
||||
};
|
||||
|
||||
export function buildHuggingFaceSecuritySignalRows(
|
||||
rows: NormalizedDatasetRows,
|
||||
): HuggingFaceSecuritySignalRow[] {
|
||||
const scansByArtifact = groupBy(rows.scanResults, (row) => row.artifact_id);
|
||||
const labelsByArtifact = groupBy(rows.labels, (row) => row.artifact_id);
|
||||
const staticFindingsByArtifact = groupBy(rows.staticFindings, (row) => row.artifact_id);
|
||||
const splitByArtifact = new Map(rows.splits.map((row) => [row.artifact_id, row.split]));
|
||||
|
||||
return rows.artifacts.flatMap((artifact) => {
|
||||
if (artifact.source_kind !== "skill" || !artifact.is_public || artifact.soft_deleted) {
|
||||
return [];
|
||||
}
|
||||
const scans = scansByArtifact.get(artifact.artifact_id) ?? [];
|
||||
const labels = labelsByArtifact.get(artifact.artifact_id) ?? [];
|
||||
const staticFindings = staticFindingsByArtifact.get(artifact.artifact_id) ?? [];
|
||||
const split = splitByArtifact.get(artifact.artifact_id);
|
||||
if (!split) return [];
|
||||
|
||||
const staticScan = scanByName(scans, "static");
|
||||
const vtScan = scanByName(scans, "virustotal");
|
||||
const skillSpectorScan = scanByName(scans, "skillspector");
|
||||
const llmScan = scanByName(scans, "llm");
|
||||
const verdict = labelBySource(labels, "moderation_consensus")?.label ?? "unknown";
|
||||
|
||||
return [
|
||||
{
|
||||
id: flatArtifactId(artifact),
|
||||
skill_slug: artifact.public_qualified_slug ?? artifact.public_slug,
|
||||
skill_version: artifact.version,
|
||||
skill_md_content: artifact.skill_md_content_redacted ?? null,
|
||||
skill_bundle_content: (artifact.bundle_files_redacted ?? []).map((file) => ({
|
||||
path: file.path,
|
||||
content: file.content,
|
||||
sha256: file.sha256,
|
||||
sizeBytes: file.size_bytes,
|
||||
})),
|
||||
clawscan_verdict: verdict,
|
||||
clawscan_confidence: llmScan?.confidence ?? null,
|
||||
clawscan_model: llmScan?.model ?? null,
|
||||
clawscan_summary: llmScan?.summary_redacted ?? null,
|
||||
static_status: staticScan?.status ?? null,
|
||||
static_finding_count: staticFindings.length,
|
||||
static_reason_codes: staticScan?.reason_codes ?? [],
|
||||
virustotal_status: vtScan?.status ?? null,
|
||||
virustotal_malicious_count: vtScan?.engine_stats?.malicious ?? null,
|
||||
virustotal_suspicious_count: vtScan?.engine_stats?.suspicious ?? null,
|
||||
virustotal_harmless_count: vtScan?.engine_stats?.harmless ?? null,
|
||||
virustotal_undetected_count: vtScan?.engine_stats?.undetected ?? null,
|
||||
skillspector_status: skillSpectorScan?.status ?? null,
|
||||
skillspector_score: skillSpectorScan?.score ?? null,
|
||||
skillspector_severity: skillSpectorScan?.severity ?? null,
|
||||
skillspector_issue_count: skillSpectorScan?.issues?.length ?? 0,
|
||||
skillspector_issue_codes: skillSpectorScan?.reason_codes ?? [],
|
||||
skillspector_issue_categories: uniqueSorted(
|
||||
(skillSpectorScan?.issues ?? []).flatMap((issue) =>
|
||||
issue.category ? [issue.category] : [],
|
||||
),
|
||||
),
|
||||
clawscan_context: buildClawScanContext({
|
||||
staticScan,
|
||||
vtScan,
|
||||
skillSpectorScan,
|
||||
staticFindings,
|
||||
}),
|
||||
split,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function buildClawScanContext(input: {
|
||||
staticScan: ScanResultRow | undefined;
|
||||
vtScan: ScanResultRow | undefined;
|
||||
skillSpectorScan: ScanResultRow | undefined;
|
||||
staticFindings: StaticFindingRow[];
|
||||
}) {
|
||||
const context: Record<string, unknown> = {};
|
||||
const { staticScan, vtScan, skillSpectorScan, staticFindings } = input;
|
||||
if (staticScan) {
|
||||
context.static = {
|
||||
status: staticScan.status,
|
||||
verdict: staticScan.verdict,
|
||||
reason_codes: staticScan.reason_codes,
|
||||
summary: staticScan.summary_redacted,
|
||||
checked_at: isoTime(staticScan.checked_at),
|
||||
scanner_version: staticScan.scanner_version,
|
||||
finding_count: staticFindings.length,
|
||||
};
|
||||
}
|
||||
if (vtScan) {
|
||||
context.virustotal = {
|
||||
status: vtScan.status,
|
||||
verdict: vtScan.verdict,
|
||||
checked_at: isoTime(vtScan.checked_at),
|
||||
scanner_version: vtScan.scanner_version,
|
||||
engine_stats: vtScan.engine_stats,
|
||||
};
|
||||
}
|
||||
if (skillSpectorScan) {
|
||||
context.skillspector = {
|
||||
status: skillSpectorScan.status,
|
||||
verdict: skillSpectorScan.verdict,
|
||||
checked_at: isoTime(skillSpectorScan.checked_at),
|
||||
scanner_version: skillSpectorScan.scanner_version,
|
||||
issue_codes: skillSpectorScan.reason_codes,
|
||||
score: skillSpectorScan.score ?? null,
|
||||
severity: skillSpectorScan.severity ?? null,
|
||||
issue_count: skillSpectorScan.issues?.length ?? 0,
|
||||
issues: (skillSpectorScan.issues ?? []).map((issue) => ({
|
||||
code: issue.code,
|
||||
category: issue.category,
|
||||
severity: issue.severity,
|
||||
confidence: issue.confidence,
|
||||
explanation: issue.explanation_redacted,
|
||||
})),
|
||||
};
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
function flatArtifactId(artifact: ArtifactRow) {
|
||||
if (artifact.artifact_sha256) return artifact.artifact_sha256;
|
||||
return hashString(artifact.artifact_id);
|
||||
}
|
||||
|
||||
function scanByName(scans: ScanResultRow[], scanner: ScanResultRow["scanner"]) {
|
||||
return scans.find((row) => row.scanner === scanner);
|
||||
}
|
||||
|
||||
function labelBySource(labels: LabelRow[], source: LabelRow["label_source"]) {
|
||||
return labels.find((row) => row.label_source === source);
|
||||
}
|
||||
|
||||
function groupBy<T>(items: T[], getKey: (item: T) => string) {
|
||||
const map = new Map<string, T[]>();
|
||||
for (const item of items) {
|
||||
const key = getKey(item);
|
||||
const group = map.get(key);
|
||||
if (group) {
|
||||
group.push(item);
|
||||
} else {
|
||||
map.set(key, [item]);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function uniqueSorted(values: string[]) {
|
||||
return Array.from(new Set(values)).sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function isoTime(value: number | null) {
|
||||
return value === null ? null : new Date(value).toISOString();
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export type SnapshotManifestInput = {
|
||||
snapshotId: string;
|
||||
sourceSnapshotId?: string | null;
|
||||
createdAt: string;
|
||||
repoGitSha: string;
|
||||
convexDeployment: string;
|
||||
@@ -16,7 +17,9 @@ export type SnapshotManifestInput = {
|
||||
clawScanFindings: number;
|
||||
labels: number;
|
||||
splits: number;
|
||||
huggingFaceRows?: number;
|
||||
};
|
||||
outputSizes?: Record<string, number>;
|
||||
scannerVersions: string[];
|
||||
modelNames: string[];
|
||||
redactionPolicyVersion: string;
|
||||
@@ -25,11 +28,20 @@ export type SnapshotManifestInput = {
|
||||
createdAtGte: number | null;
|
||||
createdAtLt: number | null;
|
||||
};
|
||||
huggingFaceDataset?: {
|
||||
repo: string;
|
||||
revision: string;
|
||||
commit: string | null;
|
||||
configNames: string[];
|
||||
splitNames: string[];
|
||||
rowCountsBySplit: Record<string, number>;
|
||||
};
|
||||
};
|
||||
|
||||
export function buildSecurityDatasetManifest(input: SnapshotManifestInput) {
|
||||
return {
|
||||
snapshot_id: input.snapshotId,
|
||||
source_snapshot_id: input.sourceSnapshotId ?? input.snapshotId,
|
||||
created_at: input.createdAt,
|
||||
repo_git_sha: input.repoGitSha,
|
||||
convex_deployment: input.convexDeployment,
|
||||
@@ -47,7 +59,9 @@ export function buildSecurityDatasetManifest(input: SnapshotManifestInput) {
|
||||
clawscan_findings: input.rowCounts.clawScanFindings,
|
||||
labels: input.rowCounts.labels,
|
||||
splits: input.rowCounts.splits,
|
||||
huggingface_rows: input.rowCounts.huggingFaceRows ?? 0,
|
||||
},
|
||||
output_sizes: input.outputSizes ?? {},
|
||||
scanner_versions: input.scannerVersions,
|
||||
model_names: input.modelNames,
|
||||
redaction_policy_version: input.redactionPolicyVersion,
|
||||
@@ -57,6 +71,7 @@ export function buildSecurityDatasetManifest(input: SnapshotManifestInput) {
|
||||
created_at_gte: input.timeWindow?.createdAtGte ?? null,
|
||||
created_at_lt: input.timeWindow?.createdAtLt ?? null,
|
||||
},
|
||||
huggingface_dataset: input.huggingFaceDataset ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user