mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-13 05:28:02 +03:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 543b256901 | |||
| 3cef7aa46b | |||
| 11f0fc50c4 | |||
| cfe1b40cf2 | |||
| f56a0864f7 | |||
| 58b092d6d0 | |||
| babddfd3f2 | |||
| 47a5696cb6 | |||
| 5d868bf60f | |||
| b57d0f1db2 | |||
| b91e5e4c94 | |||
| 2e793639f2 |
@@ -0,0 +1,74 @@
|
||||
name: Archive GitHub Traffic
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '17 3 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: traffic-archive
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
TRAFFIC_ARCHIVE_BRANCH: traffic-archive
|
||||
TRAFFIC_ARCHIVE_DIR: ../traffic-archive/traffic
|
||||
|
||||
jobs:
|
||||
archive:
|
||||
name: Capture traffic snapshot
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Prepare archive branch
|
||||
env:
|
||||
ARCHIVE_PUSH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
server="${GITHUB_SERVER_URL#https://}"
|
||||
archive_remote="https://x-access-token:${ARCHIVE_PUSH_TOKEN}@${server}/${GITHUB_REPOSITORY}.git"
|
||||
|
||||
if git ls-remote --exit-code --heads "${archive_remote}" "${TRAFFIC_ARCHIVE_BRANCH}" >/dev/null 2>&1; then
|
||||
git clone --branch "${TRAFFIC_ARCHIVE_BRANCH}" --depth 1 "${archive_remote}" ../traffic-archive
|
||||
else
|
||||
git init -b "${TRAFFIC_ARCHIVE_BRANCH}" ../traffic-archive
|
||||
git -C ../traffic-archive remote add origin "${archive_remote}"
|
||||
fi
|
||||
|
||||
mkdir -p "${TRAFFIC_ARCHIVE_DIR}"
|
||||
|
||||
- name: Collect traffic
|
||||
env:
|
||||
GH_TRAFFIC_TOKEN: ${{ secrets.TRAFFIC_ARCHIVE_TOKEN || github.token }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
run: node scripts/archive-github-traffic.mjs --archive-dir "${TRAFFIC_ARCHIVE_DIR}"
|
||||
|
||||
- name: Commit archive
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
cd ../traffic-archive
|
||||
git add traffic/archive.json traffic/summary.json
|
||||
git rm --ignore-unmatch traffic/README.md
|
||||
|
||||
if git diff --cached --quiet; then
|
||||
echo "No traffic archive changes."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git commit -m "chore(traffic): archive repository traffic $(date -u +%F)"
|
||||
git push origin HEAD:${TRAFFIC_ARCHIVE_BRANCH}
|
||||
@@ -115,6 +115,8 @@ jobs:
|
||||
run: node scripts/test-skill-release-workflow.mjs
|
||||
- name: Deploy Pages Advisory Checksums Tests
|
||||
run: node scripts/test-deploy-pages-checksums.mjs
|
||||
- name: GitHub Traffic Archive Tests
|
||||
run: node scripts/test-github-traffic-archive.mjs
|
||||
|
||||
clawsec-suite-tests:
|
||||
name: ClawSec Suite Verification Tests
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
config-file: ./.github/codeql/codeql-config.yml
|
||||
@@ -38,4 +38,4 @@ jobs:
|
||||
- name: Build project
|
||||
run: npm run build
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
|
||||
@@ -1055,7 +1055,10 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Dispatching CodeQL for branch: $BRANCH"
|
||||
EXPECTED_HEAD_SHA="$(git rev-parse HEAD)"
|
||||
DISPATCHED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
|
||||
echo "Dispatching CodeQL for branch: $BRANCH (head: $EXPECTED_HEAD_SHA, dispatched_at: $DISPATCHED_AT)"
|
||||
gh workflow run codeql.yml --ref "$BRANCH"
|
||||
|
||||
RUN_ID=""
|
||||
@@ -1064,8 +1067,13 @@ jobs:
|
||||
--workflow "CodeQL" \
|
||||
--branch "$BRANCH" \
|
||||
--event workflow_dispatch \
|
||||
--json databaseId,createdAt \
|
||||
--jq 'sort_by(.createdAt) | last | .databaseId // empty')
|
||||
--limit 50 \
|
||||
--json databaseId,createdAt,headSha \
|
||||
--jq --arg since "$DISPATCHED_AT" --arg sha "$EXPECTED_HEAD_SHA" '
|
||||
map(select(.createdAt >= $since and .headSha == $sha))
|
||||
| sort_by(.createdAt)
|
||||
| last
|
||||
| .databaseId // empty')
|
||||
if [ -n "$RUN_ID" ]; then
|
||||
break
|
||||
fi
|
||||
@@ -1073,7 +1081,13 @@ jobs:
|
||||
done
|
||||
|
||||
if [ -z "$RUN_ID" ]; then
|
||||
echo "::error::Unable to locate dispatched CodeQL run for branch $BRANCH"
|
||||
echo "::error::Unable to locate dispatched CodeQL run for branch $BRANCH after $DISPATCHED_AT (head: $EXPECTED_HEAD_SHA)"
|
||||
gh run list \
|
||||
--workflow "CodeQL" \
|
||||
--branch "$BRANCH" \
|
||||
--event workflow_dispatch \
|
||||
--limit 5 \
|
||||
--json databaseId,createdAt,headSha,status,conclusion || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -84,6 +84,6 @@ jobs:
|
||||
# Upload the results to GitHub's code scanning dashboard (optional).
|
||||
# Commenting out will disable upload of results to your repo's Code Scanning dashboard
|
||||
- name: "Upload to code-scanning"
|
||||
uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
cff-version: 1.2.0
|
||||
message: "If you use ClawSec in research or security tooling, please cite it as below."
|
||||
title: "ClawSec"
|
||||
version: "0.1.0"
|
||||
date-released: "2026-05-26"
|
||||
abstract: >-
|
||||
ClawSec is a security skill suite for AI agent platforms. It provides
|
||||
advisory monitoring, cryptographic signature verification, guarded skill
|
||||
installation, file integrity checks, and platform-specific security
|
||||
capabilities for OpenClaw, NanoClaw, Hermes, and Picoclaw deployments.
|
||||
type: software
|
||||
license: "AGPL-3.0-or-later"
|
||||
url: "https://clawsec.prompt.security/"
|
||||
repository-code: "https://github.com/prompt-security/clawsec"
|
||||
keywords:
|
||||
- ai-security
|
||||
- agent-security
|
||||
- prompt-injection
|
||||
- security-advisories
|
||||
- software-supply-chain
|
||||
- integrity-verification
|
||||
- openclaw
|
||||
- nanoclaw
|
||||
- hermes
|
||||
- picoclaw
|
||||
authors:
|
||||
- given-names: David
|
||||
family-names: Abutbul
|
||||
affiliation: "Prompt Security"
|
||||
orcid: "https://orcid.org/0009-0001-7883-3593"
|
||||
+7085
-28
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
0XMKs0QnzZYtU1YeMVNVpqzLecu8buTcBx+60hi7puHKARdshGlOSHZ8E27fo6qhz6MJx6/7zoIjCz6y+q1zBA==
|
||||
v+PiWmjIkY6zdIyI9xJX0l0aTy0Azp1+LoZR6qaiDZJnXFuSBX4Sw/x5tMdTb0xSbqdDTJOZwwWI8coPVepzBw==
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
SCkRaPMF6IYDwZuR7/JJXxpB7A7ebuMvLqK827uWX0yfEJr7l2gyLpxvHsEpWJDzE4gchxd5yqJx5qF/yqNwAg==
|
||||
Generated
+17
-15
@@ -13,7 +13,7 @@
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"react-router-dom": "^7.16.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -1997,10 +1997,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
@@ -4652,12 +4653,13 @@
|
||||
]
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.2.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
|
||||
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.2"
|
||||
"brace-expansion": "^5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
@@ -5073,9 +5075,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz",
|
||||
"integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==",
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.16.0.tgz",
|
||||
"integrity": "sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
@@ -5095,12 +5097,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz",
|
||||
"integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==",
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.16.0.tgz",
|
||||
"integrity": "sha512-kMUAbimWB5FVbF4Bce4bJsiKJWLIUHq/mEG8+CFDnCSgltptBiG5nguducmsJeGKytlCvQud9Qhzpn49iduTlA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.13.1"
|
||||
"react-router": "7.16.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
|
||||
+3
-3
@@ -23,7 +23,7 @@
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"react-router-dom": "^7.16.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -42,8 +42,8 @@
|
||||
"overrides": {
|
||||
"ajv": "6.14.0",
|
||||
"balanced-match": "4.0.3",
|
||||
"brace-expansion": "5.0.5",
|
||||
"minimatch": "10.2.4",
|
||||
"brace-expansion": "5.0.6",
|
||||
"minimatch": "10.2.5",
|
||||
"picomatch": "4.0.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
const API_ROOT = 'https://api.github.com';
|
||||
const GITHUB_API_VERSION = '2022-11-28';
|
||||
const ARCHIVE_VERSION = 1;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const SUMMARY_WINDOWS = [
|
||||
['last_14_days', 14],
|
||||
['last_30_days', 30],
|
||||
['last_90_days', 90],
|
||||
['last_365_days', 365],
|
||||
];
|
||||
|
||||
const toIsoString = (value, label) => {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw new Error(`Invalid ${label}: ${value}`);
|
||||
}
|
||||
return date.toISOString();
|
||||
};
|
||||
|
||||
const toDailyTimestamp = (value) => `${toIsoString(value, 'traffic timestamp').slice(0, 10)}T00:00:00Z`;
|
||||
const toDateKey = (value) => toIsoString(value, 'capture timestamp').slice(0, 10);
|
||||
|
||||
const toNonNegativeInteger = (value, label) => {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number) || number < 0) {
|
||||
throw new Error(`Invalid ${label}: ${value}`);
|
||||
}
|
||||
return Math.trunc(number);
|
||||
};
|
||||
|
||||
const toRequiredString = (value, label) => {
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`${label} must be a non-empty string`);
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error(`${label} must be a non-empty string`);
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const normalizeRepository = (repo) => {
|
||||
const normalized = String(repo || '').trim();
|
||||
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(normalized)) {
|
||||
throw new Error(`Repository must be in owner/name form, received: ${repo || '(empty)'}`);
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const normalizeDailyEntries = (entries, label) => {
|
||||
if (!Array.isArray(entries)) {
|
||||
throw new Error(`${label} must be an array`);
|
||||
}
|
||||
|
||||
return entries
|
||||
.map((entry) => ({
|
||||
timestamp: toDailyTimestamp(entry.timestamp),
|
||||
count: toNonNegativeInteger(entry.count, `${label}.count`),
|
||||
uniques: toNonNegativeInteger(entry.uniques, `${label}.uniques`),
|
||||
}))
|
||||
.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
||||
};
|
||||
|
||||
const normalizeReferrers = (entries) => {
|
||||
if (!Array.isArray(entries)) {
|
||||
throw new Error('referrers must be an array');
|
||||
}
|
||||
|
||||
return entries.map((entry) => ({
|
||||
referrer: toRequiredString(entry.referrer, 'referrers.referrer'),
|
||||
count: toNonNegativeInteger(entry.count, 'referrers.count'),
|
||||
uniques: toNonNegativeInteger(entry.uniques, 'referrers.uniques'),
|
||||
}));
|
||||
};
|
||||
|
||||
const normalizePaths = (entries) => {
|
||||
if (!Array.isArray(entries)) {
|
||||
throw new Error('paths must be an array');
|
||||
}
|
||||
|
||||
return entries.map((entry) => ({
|
||||
path: toRequiredString(entry.path, 'paths.path'),
|
||||
title: toRequiredString(entry.title, 'paths.title'),
|
||||
count: toNonNegativeInteger(entry.count, 'paths.count'),
|
||||
uniques: toNonNegativeInteger(entry.uniques, 'paths.uniques'),
|
||||
}));
|
||||
};
|
||||
|
||||
const upsertByKey = (existing, incoming, key) => {
|
||||
const entriesByKey = new Map();
|
||||
|
||||
for (const entry of existing || []) {
|
||||
entriesByKey.set(entry[key], entry);
|
||||
}
|
||||
for (const entry of incoming || []) {
|
||||
entriesByKey.set(entry[key], entry);
|
||||
}
|
||||
|
||||
return [...entriesByKey.values()].sort((a, b) => String(a[key]).localeCompare(String(b[key])));
|
||||
};
|
||||
|
||||
const latestEntry = (entries) => {
|
||||
if (!entries?.length) {
|
||||
return null;
|
||||
}
|
||||
return entries[entries.length - 1];
|
||||
};
|
||||
|
||||
const sumSeries = (entries) => entries.reduce(
|
||||
(totals, entry) => ({
|
||||
count: totals.count + entry.count,
|
||||
sum_daily_uniques: totals.sum_daily_uniques + entry.uniques,
|
||||
}),
|
||||
{ count: 0, sum_daily_uniques: 0 },
|
||||
);
|
||||
|
||||
const startOfUtcDay = (date) => Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
|
||||
|
||||
const summarizeWindow = (entries, days, now) => {
|
||||
const cutoff = new Date(startOfUtcDay(now) - ((days - 1) * DAY_MS));
|
||||
const filtered = entries.filter((entry) => new Date(entry.timestamp) >= cutoff);
|
||||
const totals = sumSeries(filtered);
|
||||
|
||||
return {
|
||||
days,
|
||||
count: totals.count,
|
||||
sum_daily_uniques: totals.sum_daily_uniques,
|
||||
unique_semantics: 'sum_of_daily_uniques',
|
||||
first_date: filtered[0]?.timestamp.slice(0, 10) ?? null,
|
||||
last_date: filtered.at(-1)?.timestamp.slice(0, 10) ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
const summarizeAllTime = (entries) => {
|
||||
const totals = sumSeries(entries);
|
||||
|
||||
return {
|
||||
count: totals.count,
|
||||
sum_daily_uniques: totals.sum_daily_uniques,
|
||||
unique_semantics: 'sum_of_daily_uniques',
|
||||
first_date: entries[0]?.timestamp.slice(0, 10) ?? null,
|
||||
last_date: entries.at(-1)?.timestamp.slice(0, 10) ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeExistingArchive = (archive, repository, capturedAt) => {
|
||||
if (!archive) {
|
||||
return {
|
||||
version: ARCHIVE_VERSION,
|
||||
repository,
|
||||
archive_started_at: capturedAt,
|
||||
updated_at: capturedAt,
|
||||
daily: {
|
||||
views: [],
|
||||
clones: [],
|
||||
},
|
||||
snapshots: {
|
||||
referrers: [],
|
||||
paths: [],
|
||||
},
|
||||
captures: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (archive.repository && archive.repository !== repository) {
|
||||
throw new Error(`Archive repository mismatch: ${archive.repository} != ${repository}`);
|
||||
}
|
||||
|
||||
return {
|
||||
version: ARCHIVE_VERSION,
|
||||
repository,
|
||||
archive_started_at: archive.archive_started_at || capturedAt,
|
||||
updated_at: archive.updated_at || capturedAt,
|
||||
daily: {
|
||||
views: normalizeDailyEntries(archive.daily?.views || [], 'daily.views'),
|
||||
clones: normalizeDailyEntries(archive.daily?.clones || [], 'daily.clones'),
|
||||
},
|
||||
snapshots: {
|
||||
referrers: (archive.snapshots?.referrers || []).map((snapshot) => ({
|
||||
captured_at: toIsoString(snapshot.captured_at, 'referrer snapshot timestamp'),
|
||||
date: snapshot.date || toDateKey(snapshot.captured_at),
|
||||
entries: normalizeReferrers(snapshot.entries || []),
|
||||
})),
|
||||
paths: (archive.snapshots?.paths || []).map((snapshot) => ({
|
||||
captured_at: toIsoString(snapshot.captured_at, 'path snapshot timestamp'),
|
||||
date: snapshot.date || toDateKey(snapshot.captured_at),
|
||||
entries: normalizePaths(snapshot.entries || []),
|
||||
})),
|
||||
},
|
||||
captures: (archive.captures || []).map((capture) => ({
|
||||
captured_at: toIsoString(capture.captured_at, 'capture timestamp'),
|
||||
date: capture.date || toDateKey(capture.captured_at),
|
||||
views_window: {
|
||||
count: toNonNegativeInteger(capture.views_window?.count || 0, 'captures.views_window.count'),
|
||||
uniques: toNonNegativeInteger(capture.views_window?.uniques || 0, 'captures.views_window.uniques'),
|
||||
},
|
||||
clones_window: {
|
||||
count: toNonNegativeInteger(capture.clones_window?.count || 0, 'captures.clones_window.count'),
|
||||
uniques: toNonNegativeInteger(capture.clones_window?.uniques || 0, 'captures.clones_window.uniques'),
|
||||
},
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
export const mergeTrafficArchive = (existingArchive, snapshot) => {
|
||||
const repository = normalizeRepository(snapshot.repository);
|
||||
const capturedAt = toIsoString(snapshot.captured_at, 'capture timestamp');
|
||||
const captureDate = toDateKey(capturedAt);
|
||||
const archive = normalizeExistingArchive(existingArchive, repository, capturedAt);
|
||||
|
||||
const views = normalizeDailyEntries(snapshot.views?.views || [], 'views');
|
||||
const clones = normalizeDailyEntries(snapshot.clones?.clones || [], 'clones');
|
||||
const referrerSnapshot = {
|
||||
captured_at: capturedAt,
|
||||
date: captureDate,
|
||||
entries: normalizeReferrers(snapshot.referrers || []),
|
||||
};
|
||||
const pathSnapshot = {
|
||||
captured_at: capturedAt,
|
||||
date: captureDate,
|
||||
entries: normalizePaths(snapshot.paths || []),
|
||||
};
|
||||
const capture = {
|
||||
captured_at: capturedAt,
|
||||
date: captureDate,
|
||||
views_window: {
|
||||
count: toNonNegativeInteger(snapshot.views?.count ?? sumSeries(views).count, 'views.count'),
|
||||
uniques: toNonNegativeInteger(snapshot.views?.uniques ?? sumSeries(views).sum_daily_uniques, 'views.uniques'),
|
||||
},
|
||||
clones_window: {
|
||||
count: toNonNegativeInteger(snapshot.clones?.count ?? sumSeries(clones).count, 'clones.count'),
|
||||
uniques: toNonNegativeInteger(snapshot.clones?.uniques ?? sumSeries(clones).sum_daily_uniques, 'clones.uniques'),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
...archive,
|
||||
updated_at: capturedAt,
|
||||
daily: {
|
||||
views: upsertByKey(archive.daily.views, views, 'timestamp'),
|
||||
clones: upsertByKey(archive.daily.clones, clones, 'timestamp'),
|
||||
},
|
||||
snapshots: {
|
||||
referrers: upsertByKey(archive.snapshots.referrers, [referrerSnapshot], 'date'),
|
||||
paths: upsertByKey(archive.snapshots.paths, [pathSnapshot], 'date'),
|
||||
},
|
||||
captures: upsertByKey(archive.captures, [capture], 'date'),
|
||||
};
|
||||
};
|
||||
|
||||
export const buildTrafficSummary = (archive, options = {}) => {
|
||||
const now = new Date(options.now || new Date().toISOString());
|
||||
if (Number.isNaN(now.getTime())) {
|
||||
throw new Error(`Invalid summary date: ${options.now}`);
|
||||
}
|
||||
|
||||
const views = archive.daily?.views || [];
|
||||
const clones = archive.daily?.clones || [];
|
||||
const buildMetrics = (entries) => {
|
||||
const metrics = Object.fromEntries(SUMMARY_WINDOWS.map(([key, days]) => [
|
||||
key,
|
||||
summarizeWindow(entries, days, now),
|
||||
]));
|
||||
metrics.all_time = summarizeAllTime(entries);
|
||||
return metrics;
|
||||
};
|
||||
|
||||
return {
|
||||
version: ARCHIVE_VERSION,
|
||||
repository: archive.repository,
|
||||
generated_at: now.toISOString(),
|
||||
archive_started_at: archive.archive_started_at || null,
|
||||
updated_at: archive.updated_at || null,
|
||||
source: {
|
||||
api: 'GitHub REST repository traffic endpoints',
|
||||
retention_limit: 'GitHub exposes roughly the last 14 days; this archive keeps daily snapshots long term.',
|
||||
unique_semantics: 'GitHub daily unique values are retained as sum_daily_uniques for longer windows, not deduplicated visitors.',
|
||||
},
|
||||
metrics: {
|
||||
views: buildMetrics(views),
|
||||
clones: buildMetrics(clones),
|
||||
},
|
||||
daily: {
|
||||
views,
|
||||
clones,
|
||||
},
|
||||
latest_snapshots: {
|
||||
referrers: latestEntry(archive.snapshots?.referrers || []),
|
||||
paths: latestEntry(archive.snapshots?.paths || []),
|
||||
},
|
||||
snapshot_counts: {
|
||||
referrers: archive.snapshots?.referrers?.length || 0,
|
||||
paths: archive.snapshots?.paths?.length || 0,
|
||||
captures: archive.captures?.length || 0,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const fetchJson = async ({ repo, token, pathname, fetchImpl }) => {
|
||||
const url = new URL(pathname, API_ROOT);
|
||||
const response = await fetchImpl(url, {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
'User-Agent': 'clawsec-traffic-archive',
|
||||
'X-GitHub-Api-Version': GITHUB_API_VERSION,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
const suffix = body ? ` ${body.slice(0, 500)}` : '';
|
||||
throw new Error(`GitHub traffic API request failed for ${repo}: ${url.pathname}${url.search} returned ${response.status}.${suffix}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const fetchGitHubTraffic = async ({
|
||||
repo,
|
||||
token,
|
||||
capturedAt = new Date().toISOString(),
|
||||
fetchImpl = globalThis.fetch,
|
||||
}) => {
|
||||
const repository = normalizeRepository(repo);
|
||||
if (!token) {
|
||||
throw new Error('A GitHub token is required to read repository traffic.');
|
||||
}
|
||||
if (typeof fetchImpl !== 'function') {
|
||||
throw new Error('fetch is not available in this Node runtime.');
|
||||
}
|
||||
|
||||
const encodedRepo = repository.split('/').map(encodeURIComponent).join('/');
|
||||
const request = (pathname) => fetchJson({
|
||||
repo: repository,
|
||||
token,
|
||||
pathname: `/repos/${encodedRepo}${pathname}`,
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
const [views, clones, referrers, paths] = await Promise.all([
|
||||
request('/traffic/views?per=day'),
|
||||
request('/traffic/clones?per=day'),
|
||||
request('/traffic/popular/referrers'),
|
||||
request('/traffic/popular/paths'),
|
||||
]);
|
||||
|
||||
return {
|
||||
repository,
|
||||
captured_at: toIsoString(capturedAt, 'capture timestamp'),
|
||||
views,
|
||||
clones,
|
||||
referrers,
|
||||
paths,
|
||||
};
|
||||
};
|
||||
|
||||
const readJsonIfPresent = async (file) => {
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(file, 'utf8'));
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const writeTextAtomic = async (file, content) => {
|
||||
const dir = path.dirname(file);
|
||||
const tempFile = path.join(dir, `.${path.basename(file)}.${process.pid}.${Date.now()}.tmp`);
|
||||
let handle;
|
||||
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
|
||||
try {
|
||||
handle = await fs.open(tempFile, 'w');
|
||||
await handle.writeFile(content, 'utf8');
|
||||
await handle.sync();
|
||||
await handle.close();
|
||||
handle = undefined;
|
||||
await fs.rename(tempFile, file);
|
||||
} catch (error) {
|
||||
if (handle) {
|
||||
await handle.close().catch(() => {});
|
||||
}
|
||||
await fs.unlink(tempFile).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const writeJson = async (file, value) => {
|
||||
await writeTextAtomic(file, `${JSON.stringify(value, null, 2)}\n`);
|
||||
};
|
||||
|
||||
const parseArgs = (args) => {
|
||||
const options = {};
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === '--archive-dir') {
|
||||
options.archiveDir = args[index + 1];
|
||||
index += 1;
|
||||
} else if (arg === '--repo') {
|
||||
options.repo = args[index + 1];
|
||||
index += 1;
|
||||
} else if (arg === '--captured-at') {
|
||||
options.capturedAt = args[index + 1];
|
||||
index += 1;
|
||||
} else if (arg === '--help' || arg === '-h') {
|
||||
options.help = true;
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
return options;
|
||||
};
|
||||
|
||||
const printHelp = () => {
|
||||
console.log(`Usage: node scripts/archive-github-traffic.mjs [options]
|
||||
|
||||
Options:
|
||||
--archive-dir <dir> Directory that will receive archive.json and summary.json.
|
||||
--repo <owner/repo> Repository to archive. Defaults to GITHUB_REPOSITORY.
|
||||
--captured-at <iso> Override capture time for tests or backfills.
|
||||
`);
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
const archiveDir = path.resolve(
|
||||
REPO_ROOT,
|
||||
options.archiveDir || process.env.TRAFFIC_ARCHIVE_DIR || 'traffic',
|
||||
);
|
||||
const archiveFile = path.join(archiveDir, 'archive.json');
|
||||
const summaryFile = path.join(archiveDir, 'summary.json');
|
||||
const repository = normalizeRepository(options.repo || process.env.GITHUB_REPOSITORY);
|
||||
const token = process.env.GH_TRAFFIC_TOKEN
|
||||
|| process.env.TRAFFIC_ARCHIVE_TOKEN
|
||||
|| process.env.GITHUB_TOKEN
|
||||
|| process.env.GH_TOKEN;
|
||||
const capturedAt = options.capturedAt || new Date().toISOString();
|
||||
|
||||
const snapshot = await fetchGitHubTraffic({
|
||||
repo: repository,
|
||||
token,
|
||||
capturedAt,
|
||||
});
|
||||
const existingArchive = await readJsonIfPresent(archiveFile);
|
||||
const archive = mergeTrafficArchive(existingArchive, snapshot);
|
||||
const summary = buildTrafficSummary(archive, { now: archive.updated_at });
|
||||
|
||||
await writeJson(archiveFile, archive);
|
||||
await writeJson(summaryFile, summary);
|
||||
|
||||
console.log(`Archived GitHub traffic for ${repository} at ${archive.updated_at}`);
|
||||
console.log(`Daily views retained: ${archive.daily.views.length}`);
|
||||
console.log(`Daily clones retained: ${archive.daily.clones.length}`);
|
||||
console.log(`Referrer snapshots retained: ${archive.snapshots.referrers.length}`);
|
||||
console.log(`Path snapshots retained: ${archive.snapshots.paths.length}`);
|
||||
};
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
try {
|
||||
await main();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`Failed to archive GitHub traffic: ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, readdir, readFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildTrafficSummary,
|
||||
fetchGitHubTraffic,
|
||||
mergeTrafficArchive,
|
||||
writeJson,
|
||||
} from './archive-github-traffic.mjs';
|
||||
|
||||
const TEST_REPOSITORY = 'prompt-security/clawsec';
|
||||
const TEST_CAPTURE_DATE = Date.UTC(2026, 5, 3);
|
||||
|
||||
const utcDay = (offsetFromCaptureDate = 0) => {
|
||||
const date = new Date(TEST_CAPTURE_DATE);
|
||||
date.setUTCDate(date.getUTCDate() + offsetFromCaptureDate);
|
||||
return `${date.toISOString().slice(0, 10)}T00:00:00Z`;
|
||||
};
|
||||
|
||||
const captureAt = ({
|
||||
offsetFromCaptureDate = 0,
|
||||
hour = 3,
|
||||
minute = 17,
|
||||
} = {}) => {
|
||||
const date = new Date(TEST_CAPTURE_DATE);
|
||||
date.setUTCDate(date.getUTCDate() + offsetFromCaptureDate);
|
||||
date.setUTCHours(hour, minute, 0, 0);
|
||||
return date.toISOString();
|
||||
};
|
||||
|
||||
const capturedAt = captureAt();
|
||||
|
||||
test('fetchGitHubTraffic requests the daily GitHub traffic endpoints with auth', async () => {
|
||||
const calls = [];
|
||||
const responses = {
|
||||
[`/repos/${TEST_REPOSITORY}/traffic/views?per=day`]: {
|
||||
count: 30,
|
||||
uniques: 18,
|
||||
views: [{ timestamp: utcDay(-1), count: 30, uniques: 18 }],
|
||||
},
|
||||
[`/repos/${TEST_REPOSITORY}/traffic/clones?per=day`]: {
|
||||
count: 7,
|
||||
uniques: 5,
|
||||
clones: [{ timestamp: utcDay(-1), count: 7, uniques: 5 }],
|
||||
},
|
||||
[`/repos/${TEST_REPOSITORY}/traffic/popular/referrers`]: [
|
||||
{ referrer: 'github.com', count: 12, uniques: 9 },
|
||||
],
|
||||
[`/repos/${TEST_REPOSITORY}/traffic/popular/paths`]: [
|
||||
{ path: `/${TEST_REPOSITORY}`, title: TEST_REPOSITORY, count: 16, uniques: 10 },
|
||||
],
|
||||
};
|
||||
|
||||
const fetchImpl = async (url, options) => {
|
||||
calls.push({ url: String(url), headers: options.headers });
|
||||
const pathname = new URL(url).pathname;
|
||||
const search = new URL(url).search;
|
||||
const payload = responses[`${pathname}${search}`];
|
||||
assert.ok(payload, `unexpected traffic endpoint: ${pathname}${search}`);
|
||||
return new globalThis.Response(JSON.stringify(payload), { status: 200 });
|
||||
};
|
||||
|
||||
const snapshot = await fetchGitHubTraffic({
|
||||
repo: TEST_REPOSITORY,
|
||||
token: 'test-token',
|
||||
capturedAt,
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
assert.equal(calls.length, 4);
|
||||
assert.ok(calls.every((call) => call.headers.Authorization === 'Bearer test-token'));
|
||||
assert.deepEqual(snapshot.views.views, responses[`/repos/${TEST_REPOSITORY}/traffic/views?per=day`].views);
|
||||
assert.deepEqual(snapshot.clones.clones, responses[`/repos/${TEST_REPOSITORY}/traffic/clones?per=day`].clones);
|
||||
});
|
||||
|
||||
test('mergeTrafficArchive upserts daily views and clones without double-counting overlapping windows', () => {
|
||||
const archive = mergeTrafficArchive(
|
||||
{
|
||||
version: 1,
|
||||
repository: TEST_REPOSITORY,
|
||||
updated_at: captureAt({ offsetFromCaptureDate: -1 }),
|
||||
daily: {
|
||||
views: [
|
||||
{ timestamp: utcDay(-2), count: 10, uniques: 6 },
|
||||
{ timestamp: utcDay(-1), count: 20, uniques: 12 },
|
||||
],
|
||||
clones: [
|
||||
{ timestamp: utcDay(-2), count: 2, uniques: 1 },
|
||||
],
|
||||
},
|
||||
snapshots: {
|
||||
referrers: [],
|
||||
paths: [],
|
||||
},
|
||||
captures: [],
|
||||
},
|
||||
{
|
||||
repository: TEST_REPOSITORY,
|
||||
captured_at: capturedAt,
|
||||
views: {
|
||||
views: [
|
||||
{ timestamp: utcDay(-1), count: 25, uniques: 14 },
|
||||
{ timestamp: utcDay(), count: 35, uniques: 21 },
|
||||
],
|
||||
},
|
||||
clones: {
|
||||
clones: [
|
||||
{ timestamp: utcDay(-1), count: 3, uniques: 2 },
|
||||
{ timestamp: utcDay(), count: 5, uniques: 4 },
|
||||
],
|
||||
},
|
||||
referrers: [{ referrer: 'github.com', count: 12, uniques: 9 }],
|
||||
paths: [{ path: `/${TEST_REPOSITORY}`, title: TEST_REPOSITORY, count: 16, uniques: 10 }],
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(archive.daily.views, [
|
||||
{ timestamp: utcDay(-2), count: 10, uniques: 6 },
|
||||
{ timestamp: utcDay(-1), count: 25, uniques: 14 },
|
||||
{ timestamp: utcDay(), count: 35, uniques: 21 },
|
||||
]);
|
||||
assert.deepEqual(archive.daily.clones, [
|
||||
{ timestamp: utcDay(-2), count: 2, uniques: 1 },
|
||||
{ timestamp: utcDay(-1), count: 3, uniques: 2 },
|
||||
{ timestamp: utcDay(), count: 5, uniques: 4 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('mergeTrafficArchive keeps one referrer/path snapshot per capture date', () => {
|
||||
const first = mergeTrafficArchive(undefined, {
|
||||
repository: TEST_REPOSITORY,
|
||||
captured_at: capturedAt,
|
||||
views: { views: [] },
|
||||
clones: { clones: [] },
|
||||
referrers: [{ referrer: 'github.com', count: 12, uniques: 9 }],
|
||||
paths: [{ path: `/${TEST_REPOSITORY}`, title: TEST_REPOSITORY, count: 16, uniques: 10 }],
|
||||
});
|
||||
|
||||
const second = mergeTrafficArchive(first, {
|
||||
repository: TEST_REPOSITORY,
|
||||
captured_at: captureAt({ hour: 4, minute: 0 }),
|
||||
views: { views: [] },
|
||||
clones: { clones: [] },
|
||||
referrers: [{ referrer: 'google.com', count: 8, uniques: 6 }],
|
||||
paths: [{ path: `/${TEST_REPOSITORY}/wiki`, title: 'Wiki', count: 11, uniques: 7 }],
|
||||
});
|
||||
|
||||
assert.equal(second.snapshots.referrers.length, 1);
|
||||
assert.equal(second.snapshots.paths.length, 1);
|
||||
assert.deepEqual(second.snapshots.referrers[0].entries, [
|
||||
{ referrer: 'google.com', count: 8, uniques: 6 },
|
||||
]);
|
||||
assert.deepEqual(second.snapshots.paths[0].entries, [
|
||||
{ path: `/${TEST_REPOSITORY}/wiki`, title: 'Wiki', count: 11, uniques: 7 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('mergeTrafficArchive rejects blank referrer and path fields instead of archiving empty strings', () => {
|
||||
assert.throws(
|
||||
() => mergeTrafficArchive(undefined, {
|
||||
repository: TEST_REPOSITORY,
|
||||
captured_at: capturedAt,
|
||||
views: { views: [] },
|
||||
clones: { clones: [] },
|
||||
referrers: [{ count: 12, uniques: 9 }],
|
||||
paths: [],
|
||||
}),
|
||||
/referrers\.referrer must be a non-empty string/,
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => mergeTrafficArchive(undefined, {
|
||||
repository: TEST_REPOSITORY,
|
||||
captured_at: capturedAt,
|
||||
views: { views: [] },
|
||||
clones: { clones: [] },
|
||||
referrers: [],
|
||||
paths: [{ path: `/${TEST_REPOSITORY}`, title: ' ', count: 16, uniques: 10 }],
|
||||
}),
|
||||
/paths\.title must be a non-empty string/,
|
||||
);
|
||||
});
|
||||
|
||||
test('writeJson replaces JSON through a same-directory temporary file', async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), 'clawsec-traffic-json-'));
|
||||
const file = path.join(dir, 'summary.json');
|
||||
|
||||
await writeJson(file, { version: 1, count: 1 });
|
||||
await writeJson(file, { version: 1, count: 2 });
|
||||
|
||||
assert.equal(await readFile(file, 'utf8'), '{\n "version": 1,\n "count": 2\n}\n');
|
||||
assert.deepEqual(await readdir(dir), ['summary.json']);
|
||||
});
|
||||
|
||||
test('buildTrafficSummary reports count totals and labels summed daily uniques accurately', () => {
|
||||
const archive = mergeTrafficArchive(undefined, {
|
||||
repository: TEST_REPOSITORY,
|
||||
captured_at: capturedAt,
|
||||
views: {
|
||||
views: [
|
||||
{ timestamp: utcDay(-33), count: 100, uniques: 80 },
|
||||
{ timestamp: utcDay(-1), count: 30, uniques: 18 },
|
||||
{ timestamp: utcDay(), count: 40, uniques: 22 },
|
||||
],
|
||||
},
|
||||
clones: {
|
||||
clones: [
|
||||
{ timestamp: utcDay(-1), count: 7, uniques: 5 },
|
||||
{ timestamp: utcDay(), count: 9, uniques: 6 },
|
||||
],
|
||||
},
|
||||
referrers: [],
|
||||
paths: [],
|
||||
});
|
||||
|
||||
const summary = buildTrafficSummary(archive, { now: captureAt({ hour: 12, minute: 0 }) });
|
||||
|
||||
assert.equal(summary.metrics.views.last_30_days.count, 70);
|
||||
assert.equal(summary.metrics.views.last_30_days.sum_daily_uniques, 40);
|
||||
assert.equal(summary.metrics.views.last_30_days.unique_semantics, 'sum_of_daily_uniques');
|
||||
assert.equal(summary.metrics.views.all_time.count, 170);
|
||||
assert.equal(summary.metrics.clones.last_30_days.count, 16);
|
||||
assert.equal(summary.daily.views.length, 3);
|
||||
});
|
||||
|
||||
test('traffic archive workflow uses a daily schedule and a dedicated archive branch', async () => {
|
||||
const workflowPath = new URL('../.github/workflows/archive-traffic.yml', import.meta.url);
|
||||
const workflow = await readFile(workflowPath, 'utf8');
|
||||
|
||||
assert.match(workflow, /cron:\s+'17 3 \* \* \*'/);
|
||||
assert.match(workflow, /TRAFFIC_ARCHIVE_BRANCH:\s+traffic-archive/);
|
||||
assert.match(workflow, /TRAFFIC_ARCHIVE_TOKEN/);
|
||||
assert.match(workflow, /node scripts\/archive-github-traffic\.mjs/);
|
||||
assert.match(workflow, /git add traffic\/archive\.json traffic\/summary\.json/);
|
||||
assert.match(workflow, /git rm --ignore-unmatch traffic\/README\.md/);
|
||||
assert.doesNotMatch(workflow, /git add .*traffic\/README\.md/);
|
||||
assert.match(workflow, /git push origin HEAD:\$\{TRAFFIC_ARCHIVE_BRANCH\}/);
|
||||
});
|
||||
@@ -1,5 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## [0.0.5] - 2026-06-07
|
||||
|
||||
### Security
|
||||
- Treat explicit malicious ClawHub and VirusTotal verdicts as blocking signals regardless of the numeric reputation score.
|
||||
|
||||
## [0.0.4] - 2026-05-13
|
||||
|
||||
### Security
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: clawsec-clawhub-checker
|
||||
version: 0.0.4
|
||||
version: 0.0.5
|
||||
description: ClawHub reputation checker for clawsec-suite. Adds a standalone reputation gate before guarded skill installation.
|
||||
homepage: https://clawsec.prompt.security
|
||||
clawdis:
|
||||
|
||||
@@ -35,6 +35,12 @@ function blockOnMissingScannerData(result, warning) {
|
||||
result.blocked = true;
|
||||
}
|
||||
|
||||
function blockOnMaliciousScannerData(result, warning) {
|
||||
result.warnings.push(warning);
|
||||
result.score = 0;
|
||||
result.blocked = true;
|
||||
}
|
||||
|
||||
function parseJson(raw, label, warnings) {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
@@ -58,7 +64,10 @@ function maybeApplyVersionSecuritySignals(result, versionDetails) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof security.status === "string" && security.status.toLowerCase() === "suspicious") {
|
||||
const securityStatus = typeof security.status === "string" ? security.status.toLowerCase() : "";
|
||||
if (securityStatus === "malicious") {
|
||||
blockOnMaliciousScannerData(result, "ClawHub static moderation marked the version as malicious");
|
||||
} else if (securityStatus === "suspicious") {
|
||||
result.warnings.push("ClawHub static moderation marked the version as suspicious");
|
||||
result.score -= 30;
|
||||
}
|
||||
@@ -82,7 +91,15 @@ function maybeApplyVersionSecuritySignals(result, versionDetails) {
|
||||
"";
|
||||
const normalizedStatus = vtStatus.toLowerCase();
|
||||
|
||||
if (normalizedStatus === "suspicious") {
|
||||
if (normalizedStatus === "malicious") {
|
||||
result.virustotal.push("ClawHub VirusTotal scan returned malicious");
|
||||
blockOnMaliciousScannerData(result, "ClawHub VirusTotal scan returned malicious");
|
||||
|
||||
const vtSummary = typeof vt.analysis === "string" ? vt.analysis.trim() : "";
|
||||
if (vtSummary) {
|
||||
result.virustotal.push(vtSummary.split("\n")[0]);
|
||||
}
|
||||
} else if (normalizedStatus === "suspicious") {
|
||||
result.virustotal.push("ClawHub VirusTotal scan returned suspicious");
|
||||
result.score -= 40;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clawsec-clawhub-checker",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.5",
|
||||
"description": "ClawHub reputation checker for clawsec-suite. Adds a standalone reputation gate before guarded skill installation.",
|
||||
"author": "abutbul",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
*/
|
||||
|
||||
import { fileURLToPath } from "node:url";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
@@ -58,6 +60,37 @@ function runScript(scriptPath, args, env) {
|
||||
});
|
||||
}
|
||||
|
||||
async function createMockClawhub(payload) {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawhub-reputation-test-"));
|
||||
const binDir = path.join(tmpDir, "bin");
|
||||
const mockPath = path.join(binDir, "clawhub");
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
mockPath,
|
||||
`#!/usr/bin/env node
|
||||
const payload = ${JSON.stringify(JSON.stringify(payload))};
|
||||
const command = process.argv[2] || "";
|
||||
if (command === "inspect") {
|
||||
process.stdout.write(payload);
|
||||
process.exit(0);
|
||||
}
|
||||
if (command === "search") {
|
||||
process.stdout.write("name\\nmock-skill\\nother-skill\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
process.stderr.write("unexpected clawhub command: " + process.argv.slice(2).join(" ") + "\\n");
|
||||
process.exit(2);
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
await fs.chmod(mockPath, 0o755);
|
||||
|
||||
return {
|
||||
env: { PATH: `${binDir}:${process.env.PATH}` },
|
||||
cleanup: async () => fs.rm(tmpDir, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: Invalid skill slug is rejected (command injection prevention)
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -208,6 +241,59 @@ async function testPreReleaseVersionAccepted() {
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: Explicit malicious scanner verdict blocks regardless of score
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testMaliciousVirusTotalVerdictBlocks() {
|
||||
const testName = "reputation_check: malicious VirusTotal verdict blocks install";
|
||||
const now = Date.now();
|
||||
const mock = await createMockClawhub({
|
||||
skill: {
|
||||
createdAt: now - (120 * 24 * 60 * 60 * 1000),
|
||||
updatedAt: now - (2 * 24 * 60 * 60 * 1000),
|
||||
stats: { downloads: 1000 },
|
||||
},
|
||||
owner: { handle: "trusted-publisher" },
|
||||
version: {
|
||||
security: {
|
||||
status: "clean",
|
||||
scanners: {
|
||||
vt: {
|
||||
normalizedStatus: "malicious",
|
||||
analysis: "malicious verdict from scanner",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await runScript(CHECKER_SCRIPT, ['malicious-skill', '1.0.0', '70'], mock.env);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(result.stdout);
|
||||
} catch {
|
||||
fail(testName, `Could not parse output: ${result.stdout}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
result.code === 43 &&
|
||||
parsed.safe === false &&
|
||||
parsed.warnings.some((w) => w.toLowerCase().includes("malicious")) &&
|
||||
parsed.virustotal.some((v) => v.toLowerCase().includes("malicious"))
|
||||
) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Expected malicious verdict to block, got code ${result.code}: ${JSON.stringify(parsed)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
} finally {
|
||||
await mock.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: CLI entrypoint guard works when script path is relative
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -411,6 +497,7 @@ async function runTests() {
|
||||
await testUppercaseSlugRejected();
|
||||
await testEmptySlugShowsUsage();
|
||||
await testPreReleaseVersionAccepted();
|
||||
await testMaliciousVirusTotalVerdictBlocks();
|
||||
await testRelativePathCliEntrypointWorks();
|
||||
await testInvalidThresholdRejected();
|
||||
await testEnhancedInstallerRejectsInvalidSkill();
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
0XMKs0QnzZYtU1YeMVNVpqzLecu8buTcBx+60hi7puHKARdshGlOSHZ8E27fo6qhz6MJx6/7zoIjCz6y+q1zBA==
|
||||
v+PiWmjIkY6zdIyI9xJX0l0aTy0Azp1+LoZR6qaiDZJnXFuSBX4Sw/x5tMdTb0xSbqdDTJOZwwWI8coPVepzBw==
|
||||
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## [0.0.7] - 2026-06-07
|
||||
|
||||
### Security
|
||||
- Added comparator range support for NanoClaw advisory matching and fail-closed handling for malformed affected specifiers.
|
||||
- Added strict integrity IPC request ID validation and result path containment before host-side result writes.
|
||||
|
||||
## [0.0.6] - 2026-05-24
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: clawsec-nanoclaw
|
||||
version: 0.0.6
|
||||
version: 0.0.7
|
||||
description: Use when checking for security vulnerabilities in NanoClaw skills, before installing new skills, or when asked about security advisories affecting the bot
|
||||
---
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { IntegrityMonitor } from '../guardian/integrity-monitor';
|
||||
|
||||
const RESULT_DIR = '/workspace/ipc/clawsec_results';
|
||||
const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
||||
|
||||
// ============================================================================
|
||||
// Integrity Service (Singleton)
|
||||
// ============================================================================
|
||||
@@ -84,15 +87,21 @@ export async function handleIntegrityIpc(
|
||||
logger: any
|
||||
): Promise<void> {
|
||||
const { type, requestId, groupFolder: _groupFolder } = task;
|
||||
const validatedRequestId = validateRequestId(requestId);
|
||||
|
||||
if (!validatedRequestId) {
|
||||
logger.warn({ type, requestId }, 'Invalid integrity IPC request id');
|
||||
return;
|
||||
}
|
||||
|
||||
const safeTask = { ...task, requestId: validatedRequestId };
|
||||
|
||||
if (!deps.integrityService) {
|
||||
logger.warn({ task }, 'IntegrityService not available');
|
||||
if (requestId) {
|
||||
writeResult(requestId, {
|
||||
success: false,
|
||||
error: 'IntegrityService not initialized'
|
||||
});
|
||||
}
|
||||
writeResult(validatedRequestId, {
|
||||
success: false,
|
||||
error: 'IntegrityService not initialized'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -103,31 +112,29 @@ export async function handleIntegrityIpc(
|
||||
await service.initialize();
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Failed to initialize IntegrityService');
|
||||
if (requestId) {
|
||||
writeResult(requestId, {
|
||||
success: false,
|
||||
error: `Initialization failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
});
|
||||
}
|
||||
writeResult(validatedRequestId, {
|
||||
success: false,
|
||||
error: `Initialization failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'integrity_check':
|
||||
await handleIntegrityCheck(task, service, logger);
|
||||
await handleIntegrityCheck(safeTask, service, logger);
|
||||
break;
|
||||
|
||||
case 'integrity_approve':
|
||||
await handleIntegrityApprove(task, service, logger);
|
||||
await handleIntegrityApprove(safeTask, service, logger);
|
||||
break;
|
||||
|
||||
case 'integrity_status':
|
||||
await handleIntegrityStatus(task, service, logger);
|
||||
await handleIntegrityStatus(safeTask, service, logger);
|
||||
break;
|
||||
|
||||
case 'integrity_verify_audit':
|
||||
await handleIntegrityVerifyAudit(task, service, logger);
|
||||
await handleIntegrityVerifyAudit(safeTask, service, logger);
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -280,15 +287,40 @@ async function handleIntegrityVerifyAudit(
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
function validateRequestId(requestId: unknown): string | null {
|
||||
if (typeof requestId !== 'string') return null;
|
||||
const normalized = requestId.trim();
|
||||
if (!REQUEST_ID_PATTERN.test(normalized)) return null;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function resolveResultPath(requestId: string): string {
|
||||
const safeRequestId = validateRequestId(requestId);
|
||||
if (!safeRequestId) {
|
||||
throw new Error('Invalid integrity IPC request id');
|
||||
}
|
||||
|
||||
const resultDir = RESULT_DIR;
|
||||
const normalizedResultDir = path.resolve(resultDir);
|
||||
const resultPath = path.resolve(normalizedResultDir, `${safeRequestId}.json`);
|
||||
const relativePath = path.relative(normalizedResultDir, resultPath);
|
||||
|
||||
if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
|
||||
throw new Error('Integrity IPC result path escapes result directory');
|
||||
}
|
||||
|
||||
return resultPath;
|
||||
}
|
||||
|
||||
function writeResult(requestId: string, result: any): void {
|
||||
const resultDir = '/workspace/ipc/clawsec_results';
|
||||
const resultPath = resolveResultPath(requestId);
|
||||
const resultDir = path.dirname(resultPath);
|
||||
|
||||
// Ensure directory exists
|
||||
if (!fs.existsSync(resultDir)) {
|
||||
fs.mkdirSync(resultDir, { recursive: true });
|
||||
}
|
||||
|
||||
const resultPath = path.join(resultDir, `${requestId}.json`);
|
||||
fs.writeFileSync(resultPath, JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
|
||||
@@ -86,39 +86,146 @@ export function versionMatches(version: string, versionSpec: string): boolean {
|
||||
if (v === spec) return true;
|
||||
|
||||
// Parse semver components
|
||||
const parseVersion = (ver: string): number[] => {
|
||||
const match = ver.match(/^(\d+)\.(\d+)\.(\d+)/);
|
||||
if (!match) return [];
|
||||
return [parseInt(match[1], 10), parseInt(match[2], 10), parseInt(match[3], 10)];
|
||||
type ParsedVersion = {
|
||||
major: number;
|
||||
minor: number;
|
||||
patch: number;
|
||||
prerelease: string[];
|
||||
};
|
||||
|
||||
const semverPattern = String.raw`v?\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?`;
|
||||
const semverRegex = new RegExp(
|
||||
String.raw`^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$`
|
||||
);
|
||||
|
||||
const parseVersion = (ver: string): ParsedVersion | null => {
|
||||
const match = ver.match(semverRegex);
|
||||
if (!match) return null;
|
||||
|
||||
return {
|
||||
major: parseInt(match[1], 10),
|
||||
minor: parseInt(match[2], 10),
|
||||
patch: parseInt(match[3], 10),
|
||||
prerelease: match[4] ? match[4].split('.') : [],
|
||||
};
|
||||
};
|
||||
|
||||
const comparePrereleaseIdentifiers = (left: string, right: string): number => {
|
||||
const leftIsNumeric = /^\d+$/.test(left);
|
||||
const rightIsNumeric = /^\d+$/.test(right);
|
||||
|
||||
if (leftIsNumeric && rightIsNumeric) {
|
||||
const leftValue = parseInt(left, 10);
|
||||
const rightValue = parseInt(right, 10);
|
||||
if (leftValue > rightValue) return 1;
|
||||
if (leftValue < rightValue) return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (leftIsNumeric) return -1;
|
||||
if (rightIsNumeric) return 1;
|
||||
if (left > right) return 1;
|
||||
if (left < right) return -1;
|
||||
return 0;
|
||||
};
|
||||
|
||||
const compareVersions = (left: ParsedVersion, right: ParsedVersion): number => {
|
||||
if (left.major > right.major) return 1;
|
||||
if (left.major < right.major) return -1;
|
||||
if (left.minor > right.minor) return 1;
|
||||
if (left.minor < right.minor) return -1;
|
||||
if (left.patch > right.patch) return 1;
|
||||
if (left.patch < right.patch) return -1;
|
||||
|
||||
if (left.prerelease.length === 0 && right.prerelease.length === 0) return 0;
|
||||
if (left.prerelease.length === 0) return 1;
|
||||
if (right.prerelease.length === 0) return -1;
|
||||
|
||||
const identifierCount = Math.max(left.prerelease.length, right.prerelease.length);
|
||||
for (let index = 0; index < identifierCount; index += 1) {
|
||||
const leftIdentifier = left.prerelease[index];
|
||||
const rightIdentifier = right.prerelease[index];
|
||||
|
||||
if (leftIdentifier === undefined) return -1;
|
||||
if (rightIdentifier === undefined) return 1;
|
||||
|
||||
const comparison = comparePrereleaseIdentifiers(leftIdentifier, rightIdentifier);
|
||||
if (comparison !== 0) return comparison;
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
const evaluateComparator = (comparator: string): boolean => {
|
||||
const match = comparator.trim().match(new RegExp(`^(<=|>=|<|>|=)?\\s*(${semverPattern})$`));
|
||||
if (!match) return false;
|
||||
|
||||
const operator = match[1] || '=';
|
||||
const comparatorParts = parseVersion(match[2]);
|
||||
if (!comparatorParts) return false;
|
||||
|
||||
const comparison = compareVersions(vParts, comparatorParts);
|
||||
if (operator === '<') return comparison < 0;
|
||||
if (operator === '<=') return comparison <= 0;
|
||||
if (operator === '>') return comparison > 0;
|
||||
if (operator === '>=') return comparison >= 0;
|
||||
return comparison === 0;
|
||||
};
|
||||
|
||||
const extractComparatorTokens = (range: string): string[] | null => {
|
||||
const tokenPattern = new RegExp(`(?:<=|>=|<|>|=)?\\s*${semverPattern}`, 'g');
|
||||
const tokens: string[] = [];
|
||||
let cursor = 0;
|
||||
let match = tokenPattern.exec(range);
|
||||
|
||||
while (match) {
|
||||
const gap = range.slice(cursor, match.index);
|
||||
if (!/^[\s,]*$/.test(gap)) return null;
|
||||
|
||||
tokens.push(match[0].trim());
|
||||
cursor = match.index + match[0].length;
|
||||
match = tokenPattern.exec(range);
|
||||
}
|
||||
|
||||
if (!/^[\s,]*$/.test(range.slice(cursor))) return null;
|
||||
return tokens.length > 0 ? tokens : null;
|
||||
};
|
||||
|
||||
const vParts = parseVersion(v);
|
||||
const specParts = parseVersion(spec.replace(/^[~^]/, ''));
|
||||
if (!vParts) return true;
|
||||
|
||||
if (vParts.length === 0 || specParts.length === 0) return false;
|
||||
if (/(?:<=|>=|<|>|=)/.test(spec)) {
|
||||
const comparatorTokens = extractComparatorTokens(spec);
|
||||
if (!comparatorTokens) return false;
|
||||
return comparatorTokens.every((token) => evaluateComparator(token));
|
||||
}
|
||||
|
||||
const specParts = parseVersion(spec.replace(/^[~^]/, ''));
|
||||
if (!specParts) return true;
|
||||
|
||||
// Caret range (^1.2.3): compatible with 1.x.x where x >= 2.3
|
||||
if (spec.startsWith('^')) {
|
||||
if (vParts[0] !== specParts[0]) return false;
|
||||
if (vParts[0] === 0) {
|
||||
// ^0.2.3 means 0.2.x where x >= 3
|
||||
if (vParts[1] !== specParts[1]) return false;
|
||||
return vParts[2] >= specParts[2];
|
||||
}
|
||||
// ^1.2.3 means 1.x.x where x.x >= 2.3
|
||||
if (vParts[1] > specParts[1]) return true;
|
||||
if (vParts[1] < specParts[1]) return false;
|
||||
return vParts[2] >= specParts[2];
|
||||
const upperBound =
|
||||
specParts.major > 0
|
||||
? { major: specParts.major + 1, minor: 0, patch: 0, prerelease: [] }
|
||||
: specParts.minor > 0
|
||||
? { major: 0, minor: specParts.minor + 1, patch: 0, prerelease: [] }
|
||||
: { major: 0, minor: 0, patch: specParts.patch + 1, prerelease: [] };
|
||||
|
||||
return compareVersions(vParts, specParts) >= 0 && compareVersions(vParts, upperBound) < 0;
|
||||
}
|
||||
|
||||
// Tilde range (~1.2.3): patch-level compatibility (1.2.x where x >= 3)
|
||||
if (spec.startsWith('~')) {
|
||||
if (vParts[0] !== specParts[0]) return false;
|
||||
if (vParts[1] !== specParts[1]) return false;
|
||||
return vParts[2] >= specParts[2];
|
||||
const upperBound = { major: specParts.major, minor: specParts.minor + 1, patch: 0, prerelease: [] };
|
||||
return compareVersions(vParts, specParts) >= 0 && compareVersions(vParts, upperBound) < 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
if (new RegExp(`^${semverPattern}$`).test(spec)) {
|
||||
return compareVersions(vParts, specParts) === 0;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clawsec-nanoclaw",
|
||||
"version": "0.0.6",
|
||||
"version": "0.0.7",
|
||||
"description": "ClawSec security suite for NanoClaw - Advisory feed monitoring, MCP tools for vulnerability checking, and Ed25519 signature verification for containerized WhatsApp bot agents",
|
||||
"author": "prompt-security",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import ts from 'typescript';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import vm from 'node:vm';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
@@ -12,6 +14,45 @@ function readSkillFile(relativePath) {
|
||||
return fs.readFileSync(path.join(SKILL_ROOT, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
function extractFunctionSource(source, functionName) {
|
||||
const marker = `export function ${functionName}`;
|
||||
const start = source.indexOf(marker);
|
||||
assert.notEqual(start, -1, `missing ${functionName} export`);
|
||||
|
||||
const bodyStart = source.indexOf('{', start);
|
||||
assert.notEqual(bodyStart, -1, `missing ${functionName} body`);
|
||||
|
||||
let depth = 0;
|
||||
for (let index = bodyStart; index < source.length; index += 1) {
|
||||
const char = source[index];
|
||||
if (char === '{') depth += 1;
|
||||
if (char === '}') depth -= 1;
|
||||
if (depth === 0) {
|
||||
return source.slice(start, index + 1).replace('export ', '');
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`unterminated ${functionName} body`);
|
||||
}
|
||||
|
||||
function loadVersionMatcher() {
|
||||
const source = readSkillFile('lib/advisories.ts');
|
||||
const fnSource = extractFunctionSource(source, 'versionMatches');
|
||||
const js = ts.transpileModule(
|
||||
`${fnSource}\nglobalThis.versionMatches = versionMatches;`,
|
||||
{
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.ESNext,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
},
|
||||
}
|
||||
).outputText;
|
||||
|
||||
const context = { globalThis: {} };
|
||||
vm.runInNewContext(js, context);
|
||||
return context.globalThis.versionMatches;
|
||||
}
|
||||
|
||||
test('signature verifier enforces pinned key and path policy', () => {
|
||||
const source = readSkillFile('host-services/skill-signature-handler.ts');
|
||||
|
||||
@@ -55,3 +96,39 @@ test('integrity targets and baselines use normalized absolute paths', () => {
|
||||
assert.ok(source.includes('const normalizedFilePath = path.resolve(filePath);'), 'status/approval lookups must normalize file paths');
|
||||
assert.ok(source.includes('normalizedFiles[path.resolve(filePath)] = baseline;'), 'loaded baselines must be normalized to absolute keys');
|
||||
});
|
||||
|
||||
test('advisory matcher handles comparator ranges and fails closed on malformed specs', () => {
|
||||
const versionMatches = loadVersionMatcher();
|
||||
|
||||
assert.equal(versionMatches('2026.4.20', '<2026.5.18'), true, 'less-than comparator must match vulnerable versions');
|
||||
assert.equal(versionMatches('2026.5.18', '<2026.5.18'), false, 'less-than comparator must exclude patched versions');
|
||||
assert.equal(versionMatches('2026.5.18', '<=2026.5.18'), true, 'less-than-or-equal comparator must match boundary versions');
|
||||
assert.equal(versionMatches('1.4.0', '>=1.2.0 <2.0.0'), true, 'composite comparator ranges must match all clauses');
|
||||
assert.equal(versionMatches('2.0.0', '>=1.2.0 <2.0.0'), false, 'composite comparator ranges must reject failed clauses');
|
||||
assert.equal(versionMatches('0.0.2', '<= 0.0.2'), true, 'spaced comparators must match boundary versions');
|
||||
assert.equal(versionMatches('0.0.3', '<= 0.0.2'), false, 'spaced comparators must reject versions outside range');
|
||||
assert.equal(versionMatches('1.2.3', '>= 1.0.0 <'), false, 'partially parsed comparator ranges must not match everything');
|
||||
assert.equal(versionMatches('1.2.3', 'not-a-range'), true, 'unparseable advisory specifiers must fail closed');
|
||||
});
|
||||
|
||||
test('advisory matcher preserves semver prerelease precedence', () => {
|
||||
const versionMatches = loadVersionMatcher();
|
||||
|
||||
assert.equal(versionMatches('1.2.3-beta.1', '1.2.3'), false, 'prereleases must not collapse into releases');
|
||||
assert.equal(versionMatches('1.2.3-beta.1', '=1.2.3'), false, 'explicit equality must honor prerelease data');
|
||||
assert.equal(versionMatches('1.2.3-beta.1', '<1.2.3'), true, 'prereleases must compare lower than releases');
|
||||
assert.equal(versionMatches('1.2.3', '>1.2.3-beta.1'), true, 'releases must compare higher than prereleases');
|
||||
assert.equal(versionMatches('1.2.3-beta.2', '<1.2.3-beta.10'), true, 'numeric prerelease identifiers must compare numerically');
|
||||
assert.equal(versionMatches('1.2.3+build.1', '=1.2.3+build.2'), true, 'build metadata must not affect precedence');
|
||||
assert.equal(versionMatches('1.2.3-beta.1', '^1.2.3'), false, 'caret lower bounds must honor prerelease precedence');
|
||||
assert.equal(versionMatches('1.2.3-beta.1', '~1.2.3'), false, 'tilde lower bounds must honor prerelease precedence');
|
||||
});
|
||||
|
||||
test('integrity IPC result writer validates request ids and result path containment', () => {
|
||||
const source = readSkillFile('host-services/integrity-handler.ts');
|
||||
|
||||
assert.ok(source.includes('validateRequestId(requestId)'), 'writeResult must validate request ids before writing');
|
||||
assert.ok(source.includes('resolveResultPath(requestId)'), 'writeResult must resolve result paths through a boundary helper');
|
||||
assert.ok(source.includes('path.resolve(resultDir)'), 'result directory must be normalized before containment checks');
|
||||
assert.ok(source.includes('path.relative(normalizedResultDir, resultPath)'), 'result path must be compared relative to the intended directory');
|
||||
});
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## [0.0.4] - 2026-06-07
|
||||
|
||||
### Security
|
||||
- Replaced DAST target hook execution with static hook source inspection so scanner runs never import, transpile, or invoke untrusted handler code.
|
||||
|
||||
## [0.0.3] - 2026-05-13
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: clawsec-scanner
|
||||
version: 0.0.3
|
||||
description: Automated vulnerability scanner for agent platforms. Performs dependency scanning (npm audit, pip-audit), multi-database CVE lookup (OSV, NVD, GitHub Advisory), SAST analysis (Semgrep, Bandit), and agent-specific DAST hook execution testing for OpenClaw hooks.
|
||||
version: 0.0.4
|
||||
description: Automated vulnerability scanner for agent platforms. Performs dependency scanning (npm audit, pip-audit), multi-database CVE lookup (OSV, NVD, GitHub Advisory), SAST analysis (Semgrep, Bandit), and agent-specific static hook inspection for OpenClaw hooks.
|
||||
homepage: https://clawsec.prompt.security
|
||||
clawdis:
|
||||
emoji: "🔍"
|
||||
@@ -16,7 +16,7 @@ Comprehensive security scanner for agent platforms that automates vulnerability
|
||||
- **Dependency Scanning**: Analyzes npm and Python dependencies using `npm audit` and `pip-audit` with structured JSON output parsing
|
||||
- **CVE Database Integration**: Queries OSV (primary), NVD 2.0, and GitHub Advisory Database for vulnerability enrichment
|
||||
- **SAST Analysis**: Static code analysis using Semgrep (JavaScript/TypeScript) and Bandit (Python) to detect hardcoded secrets, command injection, path traversal, and unsafe deserialization
|
||||
- **DAST Framework**: Agent-specific dynamic analysis with real OpenClaw hook execution harness (malicious input, timeout, output bounds, event mutation safety)
|
||||
- **DAST Framework**: Agent-specific static analysis of OpenClaw hook metadata and handler source without importing or invoking target code
|
||||
- **Unified Reporting**: Consolidated vulnerability reports with severity classification and remediation guidance
|
||||
- **Continuous Monitoring**: OpenClaw hook integration for automated periodic scanning
|
||||
|
||||
@@ -43,8 +43,8 @@ The scanner orchestrates four complementary scan types to provide comprehensive
|
||||
- Identifies: hardcoded secrets (API keys, tokens), command injection (`eval`, `exec`), path traversal, unsafe deserialization
|
||||
|
||||
4. **Dynamic Analysis (DAST)**
|
||||
- Real hook execution harness for OpenClaw hook handlers discovered from `HOOK.md` metadata
|
||||
- Verifies: malicious input resilience, timeout behavior, output amplification bounds, and core event mutation safety
|
||||
- Static hook inspection for OpenClaw hook handlers discovered from `HOOK.md` metadata
|
||||
- Verifies coverage and source-level risk signals without importing, transpiling, or invoking target handlers
|
||||
- Note: Traditional web DAST tools (ZAP, Burp) do not apply to agent platforms - this provides agent-specific testing
|
||||
|
||||
### Unified Reporting
|
||||
@@ -248,8 +248,8 @@ scripts/runner.sh # Orchestration layer
|
||||
├── scan_dependencies.mjs # npm audit + pip-audit
|
||||
├── query_cve_databases.mjs # OSV/NVD/GitHub API queries
|
||||
├── sast_analyzer.mjs # Semgrep + Bandit static analysis
|
||||
├── dast_runner.mjs # Dynamic security testing orchestration
|
||||
└── dast_hook_executor.mjs # Isolated real hook execution harness
|
||||
├── dast_runner.mjs # Static hook inspection orchestration
|
||||
└── dast_hook_executor.mjs # Static hook source inspection helper
|
||||
|
||||
lib/
|
||||
├── report.mjs # Result aggregation and formatting
|
||||
@@ -326,10 +326,10 @@ proc.on('close', code => {
|
||||
- Requires Python 3.8+ runtime
|
||||
- Alternative: use Docker image `returntocorp/semgrep`
|
||||
|
||||
**"TypeScript hook not executable in DAST harness"**
|
||||
- The DAST harness executes real hook handlers and transpiles `handler.ts` files when a TypeScript compiler is available
|
||||
- Install TypeScript in the scanner environment: `npm install -D typescript` (or provide `handler.js`/`handler.mjs`)
|
||||
- Without a compiler, scanner reports an `info`-level coverage finding instead of a high-severity vulnerability
|
||||
**"DAST static coverage finding"**
|
||||
- The DAST harness does not execute target hook handlers.
|
||||
- JavaScript and TypeScript hook files are read as source and reported with `info`-level static coverage findings.
|
||||
- Review any listed static signals manually when deciding whether a hook needs deeper sandboxed testing.
|
||||
|
||||
**"Concurrent scan detected"**
|
||||
- Lockfile exists: `/tmp/clawsec-scanner.lock`
|
||||
@@ -371,7 +371,7 @@ done
|
||||
node test/dependency_scanner.test.mjs # Dependency scanning
|
||||
node test/cve_integration.test.mjs # CVE database APIs
|
||||
node test/sast_engine.test.mjs # Static analysis
|
||||
node test/dast_harness.test.mjs # DAST harness execution
|
||||
node test/dast_harness.test.mjs # DAST static hook inspection
|
||||
```
|
||||
|
||||
### Linting
|
||||
@@ -456,11 +456,11 @@ npx clawhub@latest install clawsec-suite
|
||||
|
||||
## Roadmap
|
||||
|
||||
### v0.0.2 (Current)
|
||||
### v0.0.4 (Current)
|
||||
- [x] Dependency scanning (npm audit, pip-audit)
|
||||
- [x] CVE database integration (OSV, NVD, GitHub Advisory)
|
||||
- [x] SAST analysis (Semgrep, Bandit)
|
||||
- [x] Real OpenClaw hook execution harness for DAST
|
||||
- [x] Static OpenClaw hook inspection for DAST without target code execution
|
||||
- [x] Unified JSON reporting
|
||||
- [x] OpenClaw hook integration
|
||||
|
||||
|
||||
@@ -196,7 +196,7 @@ function buildAlertMessage(report: ScanReport, format: string): string {
|
||||
}
|
||||
|
||||
const handler = async (event: HookEvent, _context: HookContext): Promise<void> => {
|
||||
// DAST harness mode executes hook handlers directly; skip recursive scanner runs.
|
||||
// Preserve the legacy DAST guard so older scanner harnesses cannot recurse.
|
||||
if (process.env.CLAWSEC_DAST_HARNESS === "1" || _context?.dastMode === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const parsed = {
|
||||
@@ -47,26 +45,9 @@ function parseArgs(argv) {
|
||||
throw new Error("Missing required --handler");
|
||||
}
|
||||
|
||||
if (!parsed.eventB64) {
|
||||
throw new Error("Missing required --event");
|
||||
}
|
||||
|
||||
if (!parsed.contextB64) {
|
||||
throw new Error("Missing required --context");
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function decodeBase64Json(value, label) {
|
||||
try {
|
||||
const decoded = Buffer.from(value, "base64").toString("utf8");
|
||||
return JSON.parse(decoded);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to decode ${label}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function fileExists(filePath) {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
@@ -76,69 +57,7 @@ async function fileExists(filePath) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTypeScriptCompiler() {
|
||||
if (process.env.CLAWSEC_DAST_DISABLE_TYPESCRIPT === "1") {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const imported = await import("typescript");
|
||||
return imported.default || imported;
|
||||
} catch {
|
||||
// Ignore and try require path next.
|
||||
}
|
||||
|
||||
try {
|
||||
const req = createRequire(import.meta.url);
|
||||
return req("typescript");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function importTypeScriptModule(tsPath) {
|
||||
const tsCompiler = await loadTypeScriptCompiler();
|
||||
if (!tsCompiler || typeof tsCompiler.transpileModule !== "function") {
|
||||
throw new Error(
|
||||
`Cannot execute TypeScript hook (${tsPath}): typescript compiler not available. ` +
|
||||
"Install 'typescript' or provide a JavaScript handler file.",
|
||||
);
|
||||
}
|
||||
|
||||
const source = await fs.readFile(tsPath, "utf8");
|
||||
const transpiled = tsCompiler.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
module: tsCompiler.ModuleKind.ESNext,
|
||||
target: tsCompiler.ScriptTarget.ES2022,
|
||||
moduleResolution: tsCompiler.ModuleResolutionKind.NodeNext,
|
||||
esModuleInterop: true,
|
||||
sourceMap: false,
|
||||
inlineSourceMap: false,
|
||||
declaration: false,
|
||||
},
|
||||
fileName: tsPath,
|
||||
reportDiagnostics: false,
|
||||
});
|
||||
|
||||
const tempFile = path.join(
|
||||
path.dirname(tsPath),
|
||||
`.clawsec-dast-${path.basename(tsPath, ".ts")}-${process.pid}-${Date.now()}.mjs`,
|
||||
);
|
||||
|
||||
await fs.writeFile(tempFile, transpiled.outputText, "utf8");
|
||||
|
||||
try {
|
||||
return await import(`${pathToFileURL(tempFile).href}?ts=${Date.now()}`);
|
||||
} finally {
|
||||
try {
|
||||
await fs.unlink(tempFile);
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHookModule(handlerPath) {
|
||||
async function readHookSource(handlerPath) {
|
||||
const fullPath = path.resolve(handlerPath);
|
||||
const exists = await fileExists(fullPath);
|
||||
if (!exists) {
|
||||
@@ -146,120 +65,71 @@ async function loadHookModule(handlerPath) {
|
||||
}
|
||||
|
||||
const ext = path.extname(fullPath).toLowerCase();
|
||||
|
||||
if (ext === ".ts") {
|
||||
return importTypeScriptModule(fullPath);
|
||||
const allowedExtensions = new Set([".cjs", ".js", ".mjs", ".ts"]);
|
||||
if (!allowedExtensions.has(ext)) {
|
||||
throw new Error(`Unsupported hook handler extension: ${ext || "(none)"}`);
|
||||
}
|
||||
|
||||
return import(`${pathToFileURL(fullPath).href}?v=${Date.now()}`);
|
||||
const source = await fs.readFile(fullPath, "utf8");
|
||||
return { fullPath, ext, source };
|
||||
}
|
||||
|
||||
function resolveHandlerExport(mod, exportName) {
|
||||
function detectHandlerExport(source, exportName) {
|
||||
if (exportName && exportName !== "default") {
|
||||
if (typeof mod?.[exportName] === "function") {
|
||||
return mod[exportName];
|
||||
}
|
||||
throw new Error(`Hook export '${exportName}' is not a function`);
|
||||
const escaped = exportName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return new RegExp(`export\\s+(?:async\\s+)?function\\s+${escaped}\\b|export\\s*\\{[^}]*\\b${escaped}\\b`, "m").test(source);
|
||||
}
|
||||
|
||||
if (typeof mod?.default === "function") {
|
||||
return mod.default;
|
||||
}
|
||||
|
||||
if (typeof mod?.handler === "function") {
|
||||
return mod.handler;
|
||||
}
|
||||
|
||||
throw new Error("Hook module does not export a handler function");
|
||||
return (
|
||||
/\bexport\s+default\b/m.test(source) ||
|
||||
/\bexport\s+(?:async\s+)?function\s+handler\b/m.test(source) ||
|
||||
/\bmodule\.exports\s*=|\bexports\.handler\s*=/m.test(source)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeTimestamp(event) {
|
||||
const timestamp = event?.timestamp;
|
||||
if (typeof timestamp === "string" || typeof timestamp === "number") {
|
||||
const parsed = new Date(timestamp);
|
||||
if (!Number.isNaN(parsed.getTime())) {
|
||||
event.timestamp = parsed;
|
||||
function collectRiskSignals(source) {
|
||||
const rules = [
|
||||
["child_process", /\bchild_process\b|\bfrom\s+["']node:child_process["']|\brequire\(["']child_process["']\)/m],
|
||||
["dynamic-import", /\bimport\s*\(/m],
|
||||
["eval", /\beval\s*\(|\bnew\s+Function\s*\(/m],
|
||||
["shell-command", /\b(?:exec|spawn|execFile|fork)\s*\(/m],
|
||||
["environment-access", /\bprocess\.env\b/m],
|
||||
];
|
||||
|
||||
const signals = [];
|
||||
for (const [name, pattern] of rules) {
|
||||
if (pattern.test(source)) {
|
||||
signals.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeMessages(messages) {
|
||||
if (!Array.isArray(messages)) {
|
||||
return {
|
||||
count: 0,
|
||||
charCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
let charCount = 0;
|
||||
|
||||
for (const message of messages) {
|
||||
if (typeof message === "string") {
|
||||
charCount += message.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
charCount += JSON.stringify(message).length;
|
||||
} catch {
|
||||
charCount += 0;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
count: messages.length,
|
||||
charCount,
|
||||
};
|
||||
}
|
||||
|
||||
function coreEventShape(event) {
|
||||
return {
|
||||
type: event?.type ?? null,
|
||||
action: event?.action ?? null,
|
||||
sessionKey: event?.sessionKey ?? null,
|
||||
};
|
||||
return signals;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const event = decodeBase64Json(args.eventB64, "event payload");
|
||||
const context = decodeBase64Json(args.contextB64, "context payload");
|
||||
|
||||
normalizeTimestamp(event);
|
||||
|
||||
const startedAt = Date.now();
|
||||
const before = coreEventShape(event);
|
||||
|
||||
try {
|
||||
const mod = await loadHookModule(args.handler);
|
||||
const handler = resolveHandlerExport(mod, args.exportName);
|
||||
|
||||
await handler(event, context);
|
||||
|
||||
const after = coreEventShape(event);
|
||||
const messageSummary = summarizeMessages(event?.messages);
|
||||
const inspected = await readHookSource(args.handler);
|
||||
|
||||
const payload = {
|
||||
ok: true,
|
||||
static_only: true,
|
||||
duration_ms: Date.now() - startedAt,
|
||||
core_before: before,
|
||||
core_after: after,
|
||||
messages_count: messageSummary.count,
|
||||
messages_char_count: messageSummary.charCount,
|
||||
handler_path: inspected.fullPath,
|
||||
handler_extension: inspected.ext,
|
||||
source_bytes: Buffer.byteLength(inspected.source, "utf8"),
|
||||
source_lines: inspected.source.split(/\r?\n/).length,
|
||||
handler_export_declared: detectHandlerExport(inspected.source, args.exportName),
|
||||
risk_signals: collectRiskSignals(inspected.source),
|
||||
};
|
||||
|
||||
process.stdout.write(JSON.stringify(payload));
|
||||
} catch (error) {
|
||||
const after = coreEventShape(event);
|
||||
const messageSummary = summarizeMessages(event?.messages);
|
||||
|
||||
const payload = {
|
||||
ok: false,
|
||||
static_only: true,
|
||||
duration_ms: Date.now() - startedAt,
|
||||
core_before: before,
|
||||
core_after: after,
|
||||
messages_count: messageSummary.count,
|
||||
messages_char_count: messageSummary.charCount,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
|
||||
|
||||
@@ -24,8 +24,6 @@ import { getTimestamp } from "../lib/utils.mjs";
|
||||
*/
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30000;
|
||||
const MAX_OUTPUT_MESSAGES = 25;
|
||||
const MAX_OUTPUT_CHARS = 20000;
|
||||
const SKIP_DIR_NAMES = new Set([
|
||||
".git",
|
||||
".github",
|
||||
@@ -38,17 +36,6 @@ const SKIP_DIR_NAMES = new Set([
|
||||
".openclaw",
|
||||
]);
|
||||
|
||||
const MALICIOUS_PAYLOADS = [
|
||||
"<script>alert('XSS')</script>",
|
||||
"'; DROP TABLE users; --",
|
||||
"; rm -rf /",
|
||||
"$(whoami)",
|
||||
"..\\..\\..\\windows\\system32\\config\\sam",
|
||||
"../../../etc/passwd",
|
||||
"test\0malicious",
|
||||
"A".repeat(200000),
|
||||
];
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const HOOK_EXECUTOR_PATH = path.join(__dirname, "dast_hook_executor.mjs");
|
||||
@@ -320,43 +307,6 @@ export async function discoverHooks(targetPath) {
|
||||
return hooks;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} eventKey
|
||||
* @returns {{type: string, action: string}}
|
||||
*/
|
||||
function splitEventKey(eventKey) {
|
||||
const parts = String(eventKey ?? "").split(":");
|
||||
const type = parts.shift() || "command";
|
||||
const action = parts.join(":") || "new";
|
||||
return { type, action };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} eventKey
|
||||
* @param {string} payload
|
||||
* @param {string} targetPath
|
||||
* @returns {Record<string, unknown>}
|
||||
*/
|
||||
export function buildEvent(eventKey, payload, targetPath) {
|
||||
const { type, action } = splitEventKey(eventKey);
|
||||
|
||||
return {
|
||||
type,
|
||||
action,
|
||||
sessionKey: "clawsec-dast-session",
|
||||
timestamp: new Date().toISOString(),
|
||||
messages: [],
|
||||
context: {
|
||||
content: payload,
|
||||
transcript: payload,
|
||||
workspaceDir: path.resolve(targetPath),
|
||||
channelId: "dast-harness",
|
||||
commandSource: "dast",
|
||||
bootstrapFiles: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} HarnessInvocationResult
|
||||
* @property {boolean} timedOut
|
||||
@@ -368,33 +318,24 @@ export function buildEvent(eventKey, payload, targetPath) {
|
||||
|
||||
/**
|
||||
* @param {HookDescriptor} hook
|
||||
* @param {Record<string, unknown>} event
|
||||
* @param {Record<string, unknown>} context
|
||||
* @param {number} timeoutMs
|
||||
* @returns {Promise<HarnessInvocationResult>}
|
||||
*/
|
||||
async function invokeHookHarness(hook, event, context, timeoutMs) {
|
||||
const encodedEvent = Buffer.from(JSON.stringify(event), "utf8").toString("base64");
|
||||
const encodedContext = Buffer.from(JSON.stringify(context), "utf8").toString("base64");
|
||||
|
||||
async function inspectHookHandler(hook, timeoutMs) {
|
||||
const args = [
|
||||
HOOK_EXECUTOR_PATH,
|
||||
"--handler",
|
||||
hook.handlerPath,
|
||||
"--export",
|
||||
hook.exportName || "default",
|
||||
"--event",
|
||||
encodedEvent,
|
||||
"--context",
|
||||
encodedContext,
|
||||
];
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn("node", args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: {
|
||||
...process.env,
|
||||
CLAWSEC_DAST_HARNESS: "1",
|
||||
PATH: process.env.PATH || "",
|
||||
CLAWSEC_DAST_STATIC_INSPECTION: "1",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -462,31 +403,33 @@ function isObject(value) {
|
||||
|
||||
/**
|
||||
* @param {unknown} parsed
|
||||
* @returns {{ok: boolean, error: string, messagesCount: number, messagesCharCount: number, coreAfter: Record<string, unknown>}}
|
||||
* @returns {{ok: boolean, error: string, staticOnly: boolean, riskSignals: string[], handlerExportDeclared: boolean}}
|
||||
*/
|
||||
function normalizeHarnessPayload(parsed) {
|
||||
function normalizeStaticPayload(parsed) {
|
||||
if (!isObject(parsed)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Harness output is not an object",
|
||||
messagesCount: 0,
|
||||
messagesCharCount: 0,
|
||||
coreAfter: {},
|
||||
staticOnly: false,
|
||||
riskSignals: [],
|
||||
handlerExportDeclared: false,
|
||||
};
|
||||
}
|
||||
|
||||
const ok = parsed.ok === true;
|
||||
const error = typeof parsed.error === "string" ? parsed.error : "";
|
||||
const messagesCount = Number(parsed.messages_count ?? 0) || 0;
|
||||
const messagesCharCount = Number(parsed.messages_char_count ?? 0) || 0;
|
||||
const coreAfter = isObject(parsed.core_after) ? parsed.core_after : {};
|
||||
const staticOnly = parsed.static_only === true;
|
||||
const riskSignals = Array.isArray(parsed.risk_signals)
|
||||
? parsed.risk_signals.filter((signal) => typeof signal === "string")
|
||||
: [];
|
||||
const handlerExportDeclared = parsed.handler_export_declared === true;
|
||||
|
||||
return {
|
||||
ok,
|
||||
error,
|
||||
messagesCount,
|
||||
messagesCharCount,
|
||||
coreAfter,
|
||||
staticOnly,
|
||||
riskSignals,
|
||||
handlerExportDeclared,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -502,19 +445,6 @@ function slug(input) {
|
||||
.slice(0, 60);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} reason
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isHarnessCapabilityError(reason) {
|
||||
const normalized = String(reason ?? "").toLowerCase();
|
||||
return (
|
||||
normalized.includes("typescript compiler not available")
|
||||
|| normalized.includes("does not export a handler function")
|
||||
|| normalized.includes("is not a function")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Vulnerability[]} bucket
|
||||
* @param {string} id
|
||||
@@ -541,178 +471,74 @@ function pushHookVulnerability(bucket, id, severity, hook, eventKey, title, desc
|
||||
|
||||
/**
|
||||
* @param {HookDescriptor} hook
|
||||
* @param {string} targetPath
|
||||
* @param {string} _targetPath
|
||||
* @param {number} timeoutMs
|
||||
* @returns {Promise<Vulnerability[]>}
|
||||
*/
|
||||
async function evaluateHook(hook, targetPath, timeoutMs) {
|
||||
async function evaluateHook(hook, _targetPath, timeoutMs) {
|
||||
const findings = [];
|
||||
const invocationTimeoutMs = Math.max(1000, timeoutMs);
|
||||
// Static inspection depends only on the handler source/export, so reuse it for all hook events.
|
||||
const inspection = await inspectHookHandler(hook, invocationTimeoutMs);
|
||||
|
||||
for (const eventKey of hook.events) {
|
||||
const safeEvent = buildEvent(eventKey, "safe baseline input", targetPath);
|
||||
const safeContext = {
|
||||
skillPath: hook.hookDir,
|
||||
agentPlatform: "openclaw",
|
||||
dastMode: true,
|
||||
targetPath: path.resolve(targetPath),
|
||||
event: eventKey,
|
||||
};
|
||||
|
||||
const safeResult = await invokeHookHarness(hook, safeEvent, safeContext, invocationTimeoutMs);
|
||||
|
||||
if (safeResult.timedOut) {
|
||||
if (inspection.timedOut) {
|
||||
pushHookVulnerability(
|
||||
findings,
|
||||
`DAST-TIMEOUT-${slug(`${hook.name}-${eventKey}`)}`,
|
||||
"high",
|
||||
hook,
|
||||
eventKey,
|
||||
"Hook times out under baseline input",
|
||||
`Hook execution exceeded ${invocationTimeoutMs}ms for event '${eventKey}' under safe baseline input.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (safeResult.parseError) {
|
||||
pushHookVulnerability(
|
||||
findings,
|
||||
`DAST-HARNESS-${slug(`${hook.name}-${eventKey}`)}`,
|
||||
`DAST-STATIC-TIMEOUT-${slug(`${hook.name}-${eventKey}`)}`,
|
||||
"medium",
|
||||
hook,
|
||||
eventKey,
|
||||
"Hook harness output invalid",
|
||||
`Could not parse harness output for event '${eventKey}': ${safeResult.parseError}. stderr: ${safeResult.stderr || "(empty)"}`,
|
||||
"Hook static inspection timed out",
|
||||
`Static hook inspection exceeded ${invocationTimeoutMs}ms for event '${eventKey}'. Target code was not executed.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedSafe = normalizeHarnessPayload(safeResult.parsed);
|
||||
if (!normalizedSafe.ok) {
|
||||
const reason = normalizedSafe.error || safeResult.stderr || "unknown error";
|
||||
|
||||
if (isHarnessCapabilityError(reason)) {
|
||||
pushHookVulnerability(
|
||||
findings,
|
||||
`DAST-COVERAGE-${slug(`${hook.name}-${eventKey}`)}`,
|
||||
"info",
|
||||
hook,
|
||||
eventKey,
|
||||
"Hook not executable in local DAST harness",
|
||||
`DAST harness could not execute hook for event '${eventKey}' due to runtime capability limits: ${reason}`,
|
||||
);
|
||||
} else {
|
||||
pushHookVulnerability(
|
||||
findings,
|
||||
`DAST-CRASH-${slug(`${hook.name}-${eventKey}`)}`,
|
||||
"high",
|
||||
hook,
|
||||
eventKey,
|
||||
"Hook throws on baseline input",
|
||||
`Hook execution failed for event '${eventKey}' under safe baseline input: ${reason}`,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const mutationObserved =
|
||||
normalizedSafe.coreAfter.type !== safeEvent.type ||
|
||||
normalizedSafe.coreAfter.action !== safeEvent.action ||
|
||||
normalizedSafe.coreAfter.sessionKey !== safeEvent.sessionKey;
|
||||
|
||||
if (mutationObserved) {
|
||||
if (inspection.parseError) {
|
||||
pushHookVulnerability(
|
||||
findings,
|
||||
`DAST-MUTATION-${slug(`${hook.name}-${eventKey}`)}`,
|
||||
"low",
|
||||
hook,
|
||||
eventKey,
|
||||
"Hook mutates core event identity fields",
|
||||
`Hook changed one or more of type/action/sessionKey for event '${eventKey}'. This can cause routing side effects in OpenClaw hooks.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedSafe.messagesCount > MAX_OUTPUT_MESSAGES ||
|
||||
normalizedSafe.messagesCharCount > MAX_OUTPUT_CHARS
|
||||
) {
|
||||
pushHookVulnerability(
|
||||
findings,
|
||||
`DAST-OUTPUT-${slug(`${hook.name}-${eventKey}`)}`,
|
||||
`DAST-STATIC-HARNESS-${slug(`${hook.name}-${eventKey}`)}`,
|
||||
"medium",
|
||||
hook,
|
||||
eventKey,
|
||||
"Hook output exceeds safe bounds",
|
||||
`Hook generated ${normalizedSafe.messagesCount} messages and ${normalizedSafe.messagesCharCount} chars for baseline input. Limits: ${MAX_OUTPUT_MESSAGES} messages / ${MAX_OUTPUT_CHARS} chars.`,
|
||||
"Hook static inspection output invalid",
|
||||
`Could not parse static inspection output for event '${eventKey}': ${inspection.parseError}. stderr: ${inspection.stderr || "(empty)"}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const maliciousFailures = [];
|
||||
const maliciousTimeouts = [];
|
||||
|
||||
for (const payload of MALICIOUS_PAYLOADS) {
|
||||
const event = buildEvent(eventKey, payload, targetPath);
|
||||
const context = {
|
||||
...safeContext,
|
||||
payloadLength: payload.length,
|
||||
};
|
||||
|
||||
const result = await invokeHookHarness(hook, event, context, invocationTimeoutMs);
|
||||
|
||||
if (result.timedOut) {
|
||||
maliciousTimeouts.push(`len=${payload.length}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result.parseError) {
|
||||
maliciousFailures.push(`parse-error(${result.parseError})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalized = normalizeHarnessPayload(result.parsed);
|
||||
if (!normalized.ok) {
|
||||
maliciousFailures.push(normalized.error || "execution-error");
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.messagesCount > MAX_OUTPUT_MESSAGES ||
|
||||
normalized.messagesCharCount > MAX_OUTPUT_CHARS
|
||||
) {
|
||||
pushHookVulnerability(
|
||||
findings,
|
||||
`DAST-OUTPUT-${slug(`${hook.name}-${eventKey}`)}-${payload.length}`,
|
||||
"medium",
|
||||
hook,
|
||||
eventKey,
|
||||
"Hook output amplification under malicious input",
|
||||
`Hook generated ${normalized.messagesCount} messages and ${normalized.messagesCharCount} chars for payload length ${payload.length}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (maliciousTimeouts.length > 0) {
|
||||
const normalized = normalizeStaticPayload(inspection.parsed);
|
||||
if (!normalized.ok || !normalized.staticOnly) {
|
||||
const reason = normalized.error || inspection.stderr || "unknown static inspection error";
|
||||
pushHookVulnerability(
|
||||
findings,
|
||||
`DAST-MALICIOUS-TIMEOUT-${slug(`${hook.name}-${eventKey}`)}`,
|
||||
"high",
|
||||
`DAST-STATIC-COVERAGE-${slug(`${hook.name}-${eventKey}`)}`,
|
||||
"info",
|
||||
hook,
|
||||
eventKey,
|
||||
"Hook times out on malicious input",
|
||||
`Hook exceeded ${invocationTimeoutMs}ms for malicious payloads (${maliciousTimeouts.slice(0, 3).join(", ")}${maliciousTimeouts.length > 3 ? `, +${maliciousTimeouts.length - 3} more` : ""}).`,
|
||||
"Hook not executed during DAST static inspection",
|
||||
`DAST did not execute hook code for event '${eventKey}'. Static inspection failed with: ${reason}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (maliciousFailures.length > 0) {
|
||||
pushHookVulnerability(
|
||||
findings,
|
||||
`DAST-MALICIOUS-CRASH-${slug(`${hook.name}-${eventKey}`)}`,
|
||||
"high",
|
||||
hook,
|
||||
eventKey,
|
||||
"Hook crashes on malicious input",
|
||||
`Hook raised unhandled errors for malicious payloads. Sample errors: ${maliciousFailures.slice(0, 3).join(" | ")}${maliciousFailures.length > 3 ? ` (+${maliciousFailures.length - 3} more)` : ""}`,
|
||||
);
|
||||
}
|
||||
const signalSuffix = normalized.riskSignals.length > 0
|
||||
? ` Static signals observed: ${normalized.riskSignals.join(", ")}.`
|
||||
: "";
|
||||
const exportSuffix = normalized.handlerExportDeclared
|
||||
? ""
|
||||
: " The configured handler export was not obvious from static source inspection.";
|
||||
|
||||
pushHookVulnerability(
|
||||
findings,
|
||||
`DAST-STATIC-COVERAGE-${slug(`${hook.name}-${eventKey}`)}`,
|
||||
"info",
|
||||
hook,
|
||||
eventKey,
|
||||
"Hook inspected statically without executing target code",
|
||||
`DAST inspected the hook source for event '${eventKey}' without importing, transpiling, or invoking the handler.${signalSuffix}${exportSuffix}`,
|
||||
);
|
||||
}
|
||||
|
||||
return findings;
|
||||
@@ -778,8 +604,6 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
export { MALICIOUS_PAYLOADS };
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "clawsec-scanner",
|
||||
"version": "0.0.3",
|
||||
"description": "Automated vulnerability scanner for agent platforms. Performs dependency scanning (npm audit, pip-audit), multi-database CVE lookup (OSV, NVD, GitHub Advisory), SAST analysis (Semgrep, Bandit), and agent-specific DAST hook execution testing for OpenClaw hooks.",
|
||||
"version": "0.0.4",
|
||||
"description": "Automated vulnerability scanner for agent platforms. Performs dependency scanning (npm audit, pip-audit), multi-database CVE lookup (OSV, NVD, GitHub Advisory), SAST analysis (Semgrep, Bandit), and agent-specific static hook inspection for OpenClaw hooks.",
|
||||
"author": "prompt-security",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"homepage": "https://clawsec.prompt.security/",
|
||||
@@ -57,12 +57,12 @@
|
||||
{
|
||||
"path": "scripts/dast_runner.mjs",
|
||||
"required": true,
|
||||
"description": "Dynamic analysis harness executing OpenClaw hook handlers with malicious-input and timeout checks"
|
||||
"description": "Static OpenClaw hook inspection harness that does not execute target handlers"
|
||||
},
|
||||
{
|
||||
"path": "scripts/dast_hook_executor.mjs",
|
||||
"required": true,
|
||||
"description": "Isolated hook execution helper used by DAST for real OpenClaw harness testing"
|
||||
"description": "Static hook source inspection helper used by DAST without importing target handlers"
|
||||
},
|
||||
{
|
||||
"path": "scripts/setup_scanner_hook.mjs",
|
||||
|
||||
@@ -89,8 +89,13 @@ metadata: { "openclaw": { "events": [${eventsLiteral}] } }
|
||||
await fs.writeFile(path.join(hookDir, handlerFile), handlerSource, "utf8");
|
||||
}
|
||||
|
||||
async function testSafeHookExecutesAndDoesNotReportMisleadingHigh() {
|
||||
const testName = "DAST harness: executes real hook and reports no misleading high findings";
|
||||
async function writeExecutable(filePath, content) {
|
||||
await fs.writeFile(filePath, content, "utf8");
|
||||
await fs.chmod(filePath, 0o755);
|
||||
}
|
||||
|
||||
async function testSafeHookIsInspectedWithoutExecution() {
|
||||
const testName = "DAST harness: inspects hooks without executing target code";
|
||||
const tmp = await createTempDir();
|
||||
|
||||
try {
|
||||
@@ -125,19 +130,20 @@ export default handler;
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
const cleanSummary =
|
||||
const noHighSummary =
|
||||
result.report?.summary?.critical === 0
|
||||
&& result.report?.summary?.high === 0
|
||||
&& result.report?.summary?.medium === 0
|
||||
&& result.report?.summary?.low === 0
|
||||
&& result.report?.summary?.info === 0;
|
||||
&& result.report?.summary?.low === 0;
|
||||
const hasStaticCoverageInfo = Array.isArray(result.report?.vulnerabilities)
|
||||
&& result.report.vulnerabilities.some((v) => String(v.id || "").includes("DAST-STATIC-COVERAGE"));
|
||||
|
||||
if (result.code === 0 && markerExists && cleanSummary) {
|
||||
if (result.code === 0 && !markerExists && noHighSummary && hasStaticCoverageInfo) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(
|
||||
testName,
|
||||
`Expected exit=0, markerExists=true, clean summary. Got exit=${result.code}, markerExists=${markerExists}, summary=${JSON.stringify(result.report?.summary)} stderr=${result.stderr}`,
|
||||
`Expected exit=0, markerExists=false, static coverage info, and no high findings. Got exit=${result.code}, markerExists=${markerExists}, summary=${JSON.stringify(result.report?.summary)} findings=${JSON.stringify(result.report?.vulnerabilities || [])} stderr=${result.stderr}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -147,18 +153,24 @@ export default handler;
|
||||
}
|
||||
}
|
||||
|
||||
async function testMaliciousCrashProducesHighFinding() {
|
||||
const testName = "DAST harness: malicious input crash is reported as high";
|
||||
async function testMaliciousHandlerIsNotExecutedForPayloadChecks() {
|
||||
const testName = "DAST harness: malicious payload checks do not execute hook code";
|
||||
const tmp = await createTempDir();
|
||||
|
||||
try {
|
||||
const targetPath = path.join(tmp.path, "skill");
|
||||
const hookDir = path.join(targetPath, "hooks", "crashy-hook");
|
||||
const markerFile = path.join(hookDir, "executed.marker");
|
||||
|
||||
await writeHookFixture(
|
||||
hookDir,
|
||||
'"message:preprocessed"',
|
||||
`const handler = async (event) => {
|
||||
`import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
fs.writeFileSync(path.join(path.dirname(new URL(import.meta.url).pathname), "executed.marker"), "top-level");
|
||||
|
||||
const handler = async (event) => {
|
||||
const payload = String(event?.context?.content || "");
|
||||
if (payload.includes("<script>")) {
|
||||
throw new Error("Unhandled payload path");
|
||||
@@ -170,16 +182,21 @@ export default handler;
|
||||
);
|
||||
|
||||
const result = await runDast(targetPath, 2500);
|
||||
const hasHigh = Number(result.report?.summary?.high || 0) > 0;
|
||||
const hasCrashFinding = Array.isArray(result.report?.vulnerabilities)
|
||||
&& result.report.vulnerabilities.some((v) => String(v.id || "").includes("DAST-MALICIOUS-CRASH"));
|
||||
const markerExists = await fs
|
||||
.access(markerFile)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
const noHigh = Number(result.report?.summary?.high || 0) === 0
|
||||
&& Number(result.report?.summary?.critical || 0) === 0;
|
||||
const hasStaticCoverageInfo = Array.isArray(result.report?.vulnerabilities)
|
||||
&& result.report.vulnerabilities.some((v) => String(v.id || "").includes("DAST-STATIC-COVERAGE"));
|
||||
|
||||
if (result.code === 1 && hasHigh && hasCrashFinding) {
|
||||
if (result.code === 0 && !markerExists && noHigh && hasStaticCoverageInfo) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(
|
||||
testName,
|
||||
`Expected exit=1 and malicious crash high finding. Got exit=${result.code}, summary=${JSON.stringify(result.report?.summary)}, findings=${JSON.stringify(result.report?.vulnerabilities || [])}`,
|
||||
`Expected static inspection without marker/high findings. Got exit=${result.code}, markerExists=${markerExists}, summary=${JSON.stringify(result.report?.summary)}, findings=${JSON.stringify(result.report?.vulnerabilities || [])}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -189,8 +206,8 @@ export default handler;
|
||||
}
|
||||
}
|
||||
|
||||
async function testMissingTypeScriptCompilerIsCoverageInfo() {
|
||||
const testName = "DAST harness: missing TypeScript compiler reports coverage info, not high";
|
||||
async function testTypeScriptHookIsStaticallyInspectedWithoutCompiler() {
|
||||
const testName = "DAST harness: TypeScript hooks are statically inspected without compiler execution";
|
||||
const tmp = await createTempDir();
|
||||
|
||||
try {
|
||||
@@ -220,7 +237,7 @@ export default handler;
|
||||
const noHigh = Number(result.report?.summary?.high || 0) === 0
|
||||
&& Number(result.report?.summary?.critical || 0) === 0;
|
||||
const hasCoverageInfo = Array.isArray(result.report?.vulnerabilities)
|
||||
&& result.report.vulnerabilities.some((v) => String(v.id || "").includes("DAST-COVERAGE"));
|
||||
&& result.report.vulnerabilities.some((v) => String(v.id || "").includes("DAST-STATIC-COVERAGE"));
|
||||
const hasInfoCount = Number(result.report?.summary?.info || 0) > 0;
|
||||
|
||||
if (result.code === 0 && noHigh && hasCoverageInfo && hasInfoCount) {
|
||||
@@ -238,10 +255,76 @@ export default handler;
|
||||
}
|
||||
}
|
||||
|
||||
async function testStaticInspectionRunsOncePerHook() {
|
||||
const testName = "DAST harness: static inspection runs once per hook across events";
|
||||
const tmp = await createTempDir();
|
||||
|
||||
try {
|
||||
const targetPath = path.join(tmp.path, "skill");
|
||||
const hookDir = path.join(targetPath, "hooks", "multi-event-hook");
|
||||
const binDir = path.join(tmp.path, "bin");
|
||||
const nodeLogPath = path.join(tmp.path, "node-invocations.log");
|
||||
|
||||
await writeHookFixture(
|
||||
hookDir,
|
||||
'"agent:bootstrap", "command:new", "message:preprocessed"',
|
||||
`export default async function handler() {
|
||||
return;
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
await writeExecutable(
|
||||
path.join(binDir, "node"),
|
||||
`#!${process.execPath}
|
||||
import fs from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
fs.appendFileSync(${JSON.stringify(nodeLogPath)}, JSON.stringify(process.argv.slice(2)) + "\\n");
|
||||
const result = spawnSync(${JSON.stringify(process.execPath)}, process.argv.slice(2), {
|
||||
env: process.env,
|
||||
stdio: ["ignore", "inherit", "inherit"],
|
||||
});
|
||||
process.exit(result.status ?? 1);
|
||||
`,
|
||||
);
|
||||
|
||||
const result = await runDast(targetPath, 2500, {
|
||||
PATH: `${binDir}:${process.env.PATH}`,
|
||||
});
|
||||
|
||||
const log = await fs.readFile(nodeLogPath, "utf8");
|
||||
const invocations = log
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line));
|
||||
const executorCount = invocations.filter((args) => String(args[0] || "").endsWith("dast_hook_executor.mjs")).length;
|
||||
const staticCoverageCount = Array.isArray(result.report?.vulnerabilities)
|
||||
? result.report.vulnerabilities.filter((v) => String(v.id || "").includes("DAST-STATIC-COVERAGE")).length
|
||||
: 0;
|
||||
|
||||
if (result.code === 0 && executorCount === 1 && staticCoverageCount === 3) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(
|
||||
testName,
|
||||
`Expected one executor spawn and three per-event findings. Got exit=${result.code}, executorCount=${executorCount}, staticCoverageCount=${staticCoverageCount}, invocations=${JSON.stringify(invocations)}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
} finally {
|
||||
await tmp.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await testSafeHookExecutesAndDoesNotReportMisleadingHigh();
|
||||
await testMaliciousCrashProducesHighFinding();
|
||||
await testMissingTypeScriptCompilerIsCoverageInfo();
|
||||
await testSafeHookIsInspectedWithoutExecution();
|
||||
await testMaliciousHandlerIsNotExecutedForPayloadChecks();
|
||||
await testTypeScriptHookIsStaticallyInspectedWithoutCompiler();
|
||||
await testStaticInspectionRunsOncePerHook();
|
||||
|
||||
report();
|
||||
exitWithResults();
|
||||
|
||||
Reference in New Issue
Block a user