mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-13 13:38:03 +03:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 543b256901 | |||
| 3cef7aa46b | |||
| 11f0fc50c4 | |||
| cfe1b40cf2 | |||
| f56a0864f7 | |||
| 58b092d6d0 | |||
| babddfd3f2 | |||
| 47a5696cb6 | |||
| 5d868bf60f | |||
| b57d0f1db2 |
@@ -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
|
run: node scripts/test-skill-release-workflow.mjs
|
||||||
- name: Deploy Pages Advisory Checksums Tests
|
- name: Deploy Pages Advisory Checksums Tests
|
||||||
run: node scripts/test-deploy-pages-checksums.mjs
|
run: node scripts/test-deploy-pages-checksums.mjs
|
||||||
|
- name: GitHub Traffic Archive Tests
|
||||||
|
run: node scripts/test-github-traffic-archive.mjs
|
||||||
|
|
||||||
clawsec-suite-tests:
|
clawsec-suite-tests:
|
||||||
name: ClawSec Suite Verification Tests
|
name: ClawSec Suite Verification Tests
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ jobs:
|
|||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4
|
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
config-file: ./.github/codeql/codeql-config.yml
|
config-file: ./.github/codeql/codeql-config.yml
|
||||||
@@ -38,4 +38,4 @@ jobs:
|
|||||||
- name: Build project
|
- name: Build project
|
||||||
run: npm run build
|
run: npm run build
|
||||||
- name: Perform CodeQL Analysis
|
- 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
|
exit 1
|
||||||
fi
|
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"
|
gh workflow run codeql.yml --ref "$BRANCH"
|
||||||
|
|
||||||
RUN_ID=""
|
RUN_ID=""
|
||||||
@@ -1064,8 +1067,13 @@ jobs:
|
|||||||
--workflow "CodeQL" \
|
--workflow "CodeQL" \
|
||||||
--branch "$BRANCH" \
|
--branch "$BRANCH" \
|
||||||
--event workflow_dispatch \
|
--event workflow_dispatch \
|
||||||
--json databaseId,createdAt \
|
--limit 50 \
|
||||||
--jq 'sort_by(.createdAt) | last | .databaseId // empty')
|
--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
|
if [ -n "$RUN_ID" ]; then
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
@@ -1073,7 +1081,13 @@ jobs:
|
|||||||
done
|
done
|
||||||
|
|
||||||
if [ -z "$RUN_ID" ]; then
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,6 @@ jobs:
|
|||||||
# Upload the results to GitHub's code scanning dashboard (optional).
|
# Upload the results to GitHub's code scanning dashboard (optional).
|
||||||
# Commenting out will disable upload of results to your repo's Code Scanning dashboard
|
# Commenting out will disable upload of results to your repo's Code Scanning dashboard
|
||||||
- name: "Upload to code-scanning"
|
- 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:
|
with:
|
||||||
sarif_file: results.sarif
|
sarif_file: results.sarif
|
||||||
|
|||||||
+3493
-23
File diff suppressed because it is too large
Load Diff
@@ -1 +1 @@
|
|||||||
ie4iZN7vM+097ZsWnz+YExEB6fMbB2fWsrlmtF7+mJh5uhy7qzYmIgJ0wLWatl38mgNRutHT2PwIc7F5RzeaDA==
|
v+PiWmjIkY6zdIyI9xJX0l0aTy0Azp1+LoZR6qaiDZJnXFuSBX4Sw/x5tMdTb0xSbqdDTJOZwwWI8coPVepzBw==
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1 +1 @@
|
|||||||
P0KWbrwl6ZiBEv2w8uJ7LrbKMHPeNJX0EQBz1QFVgPd9S8xRaE6GsXuOVnkOxkn7g3PpQ6Zh7ywrICd1npiMDA==
|
SCkRaPMF6IYDwZuR7/JJXxpB7A7ebuMvLqK827uWX0yfEJr7l2gyLpxvHsEpWJDzE4gchxd5yqJx5qF/yqNwAg==
|
||||||
Generated
+17
-15
@@ -13,7 +13,7 @@
|
|||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.13.1",
|
"react-router-dom": "^7.16.0",
|
||||||
"remark-gfm": "^4.0.1"
|
"remark-gfm": "^4.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1997,10 +1997,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "5.0.5",
|
"version": "5.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^4.0.2"
|
"balanced-match": "^4.0.2"
|
||||||
},
|
},
|
||||||
@@ -4652,12 +4653,13 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "10.2.4",
|
"version": "10.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||||
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
|
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^5.0.2"
|
"brace-expansion": "^5.0.5"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "18 || 20 || >=22"
|
"node": "18 || 20 || >=22"
|
||||||
@@ -5073,9 +5075,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router": {
|
"node_modules/react-router": {
|
||||||
"version": "7.13.1",
|
"version": "7.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.16.0.tgz",
|
||||||
"integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==",
|
"integrity": "sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cookie": "^1.0.1",
|
"cookie": "^1.0.1",
|
||||||
@@ -5095,12 +5097,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router-dom": {
|
"node_modules/react-router-dom": {
|
||||||
"version": "7.13.1",
|
"version": "7.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.16.0.tgz",
|
||||||
"integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==",
|
"integrity": "sha512-kMUAbimWB5FVbF4Bce4bJsiKJWLIUHq/mEG8+CFDnCSgltptBiG5nguducmsJeGKytlCvQud9Qhzpn49iduTlA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react-router": "7.13.1"
|
"react-router": "7.16.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
|
|||||||
+3
-3
@@ -23,7 +23,7 @@
|
|||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.13.1",
|
"react-router-dom": "^7.16.0",
|
||||||
"remark-gfm": "^4.0.1"
|
"remark-gfm": "^4.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -42,8 +42,8 @@
|
|||||||
"overrides": {
|
"overrides": {
|
||||||
"ajv": "6.14.0",
|
"ajv": "6.14.0",
|
||||||
"balanced-match": "4.0.3",
|
"balanced-match": "4.0.3",
|
||||||
"brace-expansion": "5.0.5",
|
"brace-expansion": "5.0.6",
|
||||||
"minimatch": "10.2.4",
|
"minimatch": "10.2.5",
|
||||||
"picomatch": "4.0.4"
|
"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
|
# 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
|
## [0.0.4] - 2026-05-13
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: clawsec-clawhub-checker
|
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.
|
description: ClawHub reputation checker for clawsec-suite. Adds a standalone reputation gate before guarded skill installation.
|
||||||
homepage: https://clawsec.prompt.security
|
homepage: https://clawsec.prompt.security
|
||||||
clawdis:
|
clawdis:
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ function blockOnMissingScannerData(result, warning) {
|
|||||||
result.blocked = true;
|
result.blocked = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function blockOnMaliciousScannerData(result, warning) {
|
||||||
|
result.warnings.push(warning);
|
||||||
|
result.score = 0;
|
||||||
|
result.blocked = true;
|
||||||
|
}
|
||||||
|
|
||||||
function parseJson(raw, label, warnings) {
|
function parseJson(raw, label, warnings) {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(raw);
|
return JSON.parse(raw);
|
||||||
@@ -58,7 +64,10 @@ function maybeApplyVersionSecuritySignals(result, versionDetails) {
|
|||||||
return;
|
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.warnings.push("ClawHub static moderation marked the version as suspicious");
|
||||||
result.score -= 30;
|
result.score -= 30;
|
||||||
}
|
}
|
||||||
@@ -82,7 +91,15 @@ function maybeApplyVersionSecuritySignals(result, versionDetails) {
|
|||||||
"";
|
"";
|
||||||
const normalizedStatus = vtStatus.toLowerCase();
|
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.virustotal.push("ClawHub VirusTotal scan returned suspicious");
|
||||||
result.score -= 40;
|
result.score -= 40;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "clawsec-clawhub-checker",
|
"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.",
|
"description": "ClawHub reputation checker for clawsec-suite. Adds a standalone reputation gate before guarded skill installation.",
|
||||||
"author": "abutbul",
|
"author": "abutbul",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { spawn } from "node:child_process";
|
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)
|
// 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
|
// Test: CLI entrypoint guard works when script path is relative
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
@@ -411,6 +497,7 @@ async function runTests() {
|
|||||||
await testUppercaseSlugRejected();
|
await testUppercaseSlugRejected();
|
||||||
await testEmptySlugShowsUsage();
|
await testEmptySlugShowsUsage();
|
||||||
await testPreReleaseVersionAccepted();
|
await testPreReleaseVersionAccepted();
|
||||||
|
await testMaliciousVirusTotalVerdictBlocks();
|
||||||
await testRelativePathCliEntrypointWorks();
|
await testRelativePathCliEntrypointWorks();
|
||||||
await testInvalidThresholdRejected();
|
await testInvalidThresholdRejected();
|
||||||
await testEnhancedInstallerRejectsInvalidSkill();
|
await testEnhancedInstallerRejectsInvalidSkill();
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1 +1 @@
|
|||||||
ie4iZN7vM+097ZsWnz+YExEB6fMbB2fWsrlmtF7+mJh5uhy7qzYmIgJ0wLWatl38mgNRutHT2PwIc7F5RzeaDA==
|
v+PiWmjIkY6zdIyI9xJX0l0aTy0Azp1+LoZR6qaiDZJnXFuSBX4Sw/x5tMdTb0xSbqdDTJOZwwWI8coPVepzBw==
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
# Changelog
|
# 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
|
## [0.0.6] - 2026-05-24
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: clawsec-nanoclaw
|
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
|
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 path from 'path';
|
||||||
import { IntegrityMonitor } from '../guardian/integrity-monitor';
|
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)
|
// Integrity Service (Singleton)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -84,15 +87,21 @@ export async function handleIntegrityIpc(
|
|||||||
logger: any
|
logger: any
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { type, requestId, groupFolder: _groupFolder } = task;
|
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) {
|
if (!deps.integrityService) {
|
||||||
logger.warn({ task }, 'IntegrityService not available');
|
logger.warn({ task }, 'IntegrityService not available');
|
||||||
if (requestId) {
|
writeResult(validatedRequestId, {
|
||||||
writeResult(requestId, {
|
success: false,
|
||||||
success: false,
|
error: 'IntegrityService not initialized'
|
||||||
error: 'IntegrityService not initialized'
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,31 +112,29 @@ export async function handleIntegrityIpc(
|
|||||||
await service.initialize();
|
await service.initialize();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, 'Failed to initialize IntegrityService');
|
logger.error({ error }, 'Failed to initialize IntegrityService');
|
||||||
if (requestId) {
|
writeResult(validatedRequestId, {
|
||||||
writeResult(requestId, {
|
success: false,
|
||||||
success: false,
|
error: `Initialization failed: ${error instanceof Error ? error.message : String(error)}`
|
||||||
error: `Initialization failed: ${error instanceof Error ? error.message : String(error)}`
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'integrity_check':
|
case 'integrity_check':
|
||||||
await handleIntegrityCheck(task, service, logger);
|
await handleIntegrityCheck(safeTask, service, logger);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'integrity_approve':
|
case 'integrity_approve':
|
||||||
await handleIntegrityApprove(task, service, logger);
|
await handleIntegrityApprove(safeTask, service, logger);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'integrity_status':
|
case 'integrity_status':
|
||||||
await handleIntegrityStatus(task, service, logger);
|
await handleIntegrityStatus(safeTask, service, logger);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'integrity_verify_audit':
|
case 'integrity_verify_audit':
|
||||||
await handleIntegrityVerifyAudit(task, service, logger);
|
await handleIntegrityVerifyAudit(safeTask, service, logger);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -280,15 +287,40 @@ async function handleIntegrityVerifyAudit(
|
|||||||
// Helper Functions
|
// 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 {
|
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
|
// Ensure directory exists
|
||||||
if (!fs.existsSync(resultDir)) {
|
if (!fs.existsSync(resultDir)) {
|
||||||
fs.mkdirSync(resultDir, { recursive: true });
|
fs.mkdirSync(resultDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const resultPath = path.join(resultDir, `${requestId}.json`);
|
|
||||||
fs.writeFileSync(resultPath, JSON.stringify(result, null, 2));
|
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;
|
if (v === spec) return true;
|
||||||
|
|
||||||
// Parse semver components
|
// Parse semver components
|
||||||
const parseVersion = (ver: string): number[] => {
|
type ParsedVersion = {
|
||||||
const match = ver.match(/^(\d+)\.(\d+)\.(\d+)/);
|
major: number;
|
||||||
if (!match) return [];
|
minor: number;
|
||||||
return [parseInt(match[1], 10), parseInt(match[2], 10), parseInt(match[3], 10)];
|
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 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
|
// Caret range (^1.2.3): compatible with 1.x.x where x >= 2.3
|
||||||
if (spec.startsWith('^')) {
|
if (spec.startsWith('^')) {
|
||||||
if (vParts[0] !== specParts[0]) return false;
|
const upperBound =
|
||||||
if (vParts[0] === 0) {
|
specParts.major > 0
|
||||||
// ^0.2.3 means 0.2.x where x >= 3
|
? { major: specParts.major + 1, minor: 0, patch: 0, prerelease: [] }
|
||||||
if (vParts[1] !== specParts[1]) return false;
|
: specParts.minor > 0
|
||||||
return vParts[2] >= specParts[2];
|
? { major: 0, minor: specParts.minor + 1, patch: 0, prerelease: [] }
|
||||||
}
|
: { major: 0, minor: 0, patch: specParts.patch + 1, prerelease: [] };
|
||||||
// ^1.2.3 means 1.x.x where x.x >= 2.3
|
|
||||||
if (vParts[1] > specParts[1]) return true;
|
return compareVersions(vParts, specParts) >= 0 && compareVersions(vParts, upperBound) < 0;
|
||||||
if (vParts[1] < specParts[1]) return false;
|
|
||||||
return vParts[2] >= specParts[2];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tilde range (~1.2.3): patch-level compatibility (1.2.x where x >= 3)
|
// Tilde range (~1.2.3): patch-level compatibility (1.2.x where x >= 3)
|
||||||
if (spec.startsWith('~')) {
|
if (spec.startsWith('~')) {
|
||||||
if (vParts[0] !== specParts[0]) return false;
|
const upperBound = { major: specParts.major, minor: specParts.minor + 1, patch: 0, prerelease: [] };
|
||||||
if (vParts[1] !== specParts[1]) return false;
|
return compareVersions(vParts, specParts) >= 0 && compareVersions(vParts, upperBound) < 0;
|
||||||
return vParts[2] >= specParts[2];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
if (new RegExp(`^${semverPattern}$`).test(spec)) {
|
||||||
|
return compareVersions(vParts, specParts) === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "clawsec-nanoclaw",
|
"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",
|
"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",
|
"author": "prompt-security",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import ts from 'typescript';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
import vm from 'node:vm';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
@@ -12,6 +14,45 @@ function readSkillFile(relativePath) {
|
|||||||
return fs.readFileSync(path.join(SKILL_ROOT, relativePath), 'utf8');
|
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', () => {
|
test('signature verifier enforces pinned key and path policy', () => {
|
||||||
const source = readSkillFile('host-services/skill-signature-handler.ts');
|
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('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');
|
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
|
# 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
|
## [0.0.3] - 2026-05-13
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: clawsec-scanner
|
name: clawsec-scanner
|
||||||
version: 0.0.3
|
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 DAST hook execution testing for OpenClaw hooks.
|
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
|
homepage: https://clawsec.prompt.security
|
||||||
clawdis:
|
clawdis:
|
||||||
emoji: "🔍"
|
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
|
- **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
|
- **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
|
- **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
|
- **Unified Reporting**: Consolidated vulnerability reports with severity classification and remediation guidance
|
||||||
- **Continuous Monitoring**: OpenClaw hook integration for automated periodic scanning
|
- **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
|
- Identifies: hardcoded secrets (API keys, tokens), command injection (`eval`, `exec`), path traversal, unsafe deserialization
|
||||||
|
|
||||||
4. **Dynamic Analysis (DAST)**
|
4. **Dynamic Analysis (DAST)**
|
||||||
- Real hook execution harness for OpenClaw hook handlers discovered from `HOOK.md` metadata
|
- Static hook inspection for OpenClaw hook handlers discovered from `HOOK.md` metadata
|
||||||
- Verifies: malicious input resilience, timeout behavior, output amplification bounds, and core event mutation safety
|
- 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
|
- Note: Traditional web DAST tools (ZAP, Burp) do not apply to agent platforms - this provides agent-specific testing
|
||||||
|
|
||||||
### Unified Reporting
|
### Unified Reporting
|
||||||
@@ -248,8 +248,8 @@ scripts/runner.sh # Orchestration layer
|
|||||||
├── scan_dependencies.mjs # npm audit + pip-audit
|
├── scan_dependencies.mjs # npm audit + pip-audit
|
||||||
├── query_cve_databases.mjs # OSV/NVD/GitHub API queries
|
├── query_cve_databases.mjs # OSV/NVD/GitHub API queries
|
||||||
├── sast_analyzer.mjs # Semgrep + Bandit static analysis
|
├── sast_analyzer.mjs # Semgrep + Bandit static analysis
|
||||||
├── dast_runner.mjs # Dynamic security testing orchestration
|
├── dast_runner.mjs # Static hook inspection orchestration
|
||||||
└── dast_hook_executor.mjs # Isolated real hook execution harness
|
└── dast_hook_executor.mjs # Static hook source inspection helper
|
||||||
|
|
||||||
lib/
|
lib/
|
||||||
├── report.mjs # Result aggregation and formatting
|
├── report.mjs # Result aggregation and formatting
|
||||||
@@ -326,10 +326,10 @@ proc.on('close', code => {
|
|||||||
- Requires Python 3.8+ runtime
|
- Requires Python 3.8+ runtime
|
||||||
- Alternative: use Docker image `returntocorp/semgrep`
|
- Alternative: use Docker image `returntocorp/semgrep`
|
||||||
|
|
||||||
**"TypeScript hook not executable in DAST harness"**
|
**"DAST static coverage finding"**
|
||||||
- The DAST harness executes real hook handlers and transpiles `handler.ts` files when a TypeScript compiler is available
|
- The DAST harness does not execute target hook handlers.
|
||||||
- Install TypeScript in the scanner environment: `npm install -D typescript` (or provide `handler.js`/`handler.mjs`)
|
- JavaScript and TypeScript hook files are read as source and reported with `info`-level static coverage findings.
|
||||||
- Without a compiler, scanner reports an `info`-level coverage finding instead of a high-severity vulnerability
|
- Review any listed static signals manually when deciding whether a hook needs deeper sandboxed testing.
|
||||||
|
|
||||||
**"Concurrent scan detected"**
|
**"Concurrent scan detected"**
|
||||||
- Lockfile exists: `/tmp/clawsec-scanner.lock`
|
- Lockfile exists: `/tmp/clawsec-scanner.lock`
|
||||||
@@ -371,7 +371,7 @@ done
|
|||||||
node test/dependency_scanner.test.mjs # Dependency scanning
|
node test/dependency_scanner.test.mjs # Dependency scanning
|
||||||
node test/cve_integration.test.mjs # CVE database APIs
|
node test/cve_integration.test.mjs # CVE database APIs
|
||||||
node test/sast_engine.test.mjs # Static analysis
|
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
|
### Linting
|
||||||
@@ -456,11 +456,11 @@ npx clawhub@latest install clawsec-suite
|
|||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
### v0.0.2 (Current)
|
### v0.0.4 (Current)
|
||||||
- [x] Dependency scanning (npm audit, pip-audit)
|
- [x] Dependency scanning (npm audit, pip-audit)
|
||||||
- [x] CVE database integration (OSV, NVD, GitHub Advisory)
|
- [x] CVE database integration (OSV, NVD, GitHub Advisory)
|
||||||
- [x] SAST analysis (Semgrep, Bandit)
|
- [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] Unified JSON reporting
|
||||||
- [x] OpenClaw hook integration
|
- [x] OpenClaw hook integration
|
||||||
|
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ function buildAlertMessage(report: ScanReport, format: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handler = async (event: HookEvent, _context: HookContext): Promise<void> => {
|
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) {
|
if (process.env.CLAWSEC_DAST_HARNESS === "1" || _context?.dastMode === true) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { createRequire } from "node:module";
|
|
||||||
import { pathToFileURL } from "node:url";
|
|
||||||
|
|
||||||
function parseArgs(argv) {
|
function parseArgs(argv) {
|
||||||
const parsed = {
|
const parsed = {
|
||||||
@@ -47,26 +45,9 @@ function parseArgs(argv) {
|
|||||||
throw new Error("Missing required --handler");
|
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;
|
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) {
|
async function fileExists(filePath) {
|
||||||
try {
|
try {
|
||||||
await fs.access(filePath);
|
await fs.access(filePath);
|
||||||
@@ -76,69 +57,7 @@ async function fileExists(filePath) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadTypeScriptCompiler() {
|
async function readHookSource(handlerPath) {
|
||||||
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) {
|
|
||||||
const fullPath = path.resolve(handlerPath);
|
const fullPath = path.resolve(handlerPath);
|
||||||
const exists = await fileExists(fullPath);
|
const exists = await fileExists(fullPath);
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
@@ -146,120 +65,71 @@ async function loadHookModule(handlerPath) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ext = path.extname(fullPath).toLowerCase();
|
const ext = path.extname(fullPath).toLowerCase();
|
||||||
|
const allowedExtensions = new Set([".cjs", ".js", ".mjs", ".ts"]);
|
||||||
if (ext === ".ts") {
|
if (!allowedExtensions.has(ext)) {
|
||||||
return importTypeScriptModule(fullPath);
|
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 (exportName && exportName !== "default") {
|
||||||
if (typeof mod?.[exportName] === "function") {
|
const escaped = exportName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
return mod[exportName];
|
return new RegExp(`export\\s+(?:async\\s+)?function\\s+${escaped}\\b|export\\s*\\{[^}]*\\b${escaped}\\b`, "m").test(source);
|
||||||
}
|
|
||||||
throw new Error(`Hook export '${exportName}' is not a function`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof mod?.default === "function") {
|
return (
|
||||||
return mod.default;
|
/\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)
|
||||||
if (typeof mod?.handler === "function") {
|
);
|
||||||
return mod.handler;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error("Hook module does not export a handler function");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeTimestamp(event) {
|
function collectRiskSignals(source) {
|
||||||
const timestamp = event?.timestamp;
|
const rules = [
|
||||||
if (typeof timestamp === "string" || typeof timestamp === "number") {
|
["child_process", /\bchild_process\b|\bfrom\s+["']node:child_process["']|\brequire\(["']child_process["']\)/m],
|
||||||
const parsed = new Date(timestamp);
|
["dynamic-import", /\bimport\s*\(/m],
|
||||||
if (!Number.isNaN(parsed.getTime())) {
|
["eval", /\beval\s*\(|\bnew\s+Function\s*\(/m],
|
||||||
event.timestamp = parsed;
|
["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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
return signals;
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const args = parseArgs(process.argv.slice(2));
|
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 startedAt = Date.now();
|
||||||
const before = coreEventShape(event);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const mod = await loadHookModule(args.handler);
|
const inspected = await readHookSource(args.handler);
|
||||||
const handler = resolveHandlerExport(mod, args.exportName);
|
|
||||||
|
|
||||||
await handler(event, context);
|
|
||||||
|
|
||||||
const after = coreEventShape(event);
|
|
||||||
const messageSummary = summarizeMessages(event?.messages);
|
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
ok: true,
|
ok: true,
|
||||||
|
static_only: true,
|
||||||
duration_ms: Date.now() - startedAt,
|
duration_ms: Date.now() - startedAt,
|
||||||
core_before: before,
|
handler_path: inspected.fullPath,
|
||||||
core_after: after,
|
handler_extension: inspected.ext,
|
||||||
messages_count: messageSummary.count,
|
source_bytes: Buffer.byteLength(inspected.source, "utf8"),
|
||||||
messages_char_count: messageSummary.charCount,
|
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));
|
process.stdout.write(JSON.stringify(payload));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const after = coreEventShape(event);
|
|
||||||
const messageSummary = summarizeMessages(event?.messages);
|
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
ok: false,
|
ok: false,
|
||||||
|
static_only: true,
|
||||||
duration_ms: Date.now() - startedAt,
|
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),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,6 @@ import { getTimestamp } from "../lib/utils.mjs";
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT_MS = 30000;
|
const DEFAULT_TIMEOUT_MS = 30000;
|
||||||
const MAX_OUTPUT_MESSAGES = 25;
|
|
||||||
const MAX_OUTPUT_CHARS = 20000;
|
|
||||||
const SKIP_DIR_NAMES = new Set([
|
const SKIP_DIR_NAMES = new Set([
|
||||||
".git",
|
".git",
|
||||||
".github",
|
".github",
|
||||||
@@ -38,17 +36,6 @@ const SKIP_DIR_NAMES = new Set([
|
|||||||
".openclaw",
|
".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 __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
const HOOK_EXECUTOR_PATH = path.join(__dirname, "dast_hook_executor.mjs");
|
const HOOK_EXECUTOR_PATH = path.join(__dirname, "dast_hook_executor.mjs");
|
||||||
@@ -320,43 +307,6 @@ export async function discoverHooks(targetPath) {
|
|||||||
return hooks;
|
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
|
* @typedef {Object} HarnessInvocationResult
|
||||||
* @property {boolean} timedOut
|
* @property {boolean} timedOut
|
||||||
@@ -368,33 +318,24 @@ export function buildEvent(eventKey, payload, targetPath) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {HookDescriptor} hook
|
* @param {HookDescriptor} hook
|
||||||
* @param {Record<string, unknown>} event
|
|
||||||
* @param {Record<string, unknown>} context
|
|
||||||
* @param {number} timeoutMs
|
* @param {number} timeoutMs
|
||||||
* @returns {Promise<HarnessInvocationResult>}
|
* @returns {Promise<HarnessInvocationResult>}
|
||||||
*/
|
*/
|
||||||
async function invokeHookHarness(hook, event, context, timeoutMs) {
|
async function inspectHookHandler(hook, timeoutMs) {
|
||||||
const encodedEvent = Buffer.from(JSON.stringify(event), "utf8").toString("base64");
|
|
||||||
const encodedContext = Buffer.from(JSON.stringify(context), "utf8").toString("base64");
|
|
||||||
|
|
||||||
const args = [
|
const args = [
|
||||||
HOOK_EXECUTOR_PATH,
|
HOOK_EXECUTOR_PATH,
|
||||||
"--handler",
|
"--handler",
|
||||||
hook.handlerPath,
|
hook.handlerPath,
|
||||||
"--export",
|
"--export",
|
||||||
hook.exportName || "default",
|
hook.exportName || "default",
|
||||||
"--event",
|
|
||||||
encodedEvent,
|
|
||||||
"--context",
|
|
||||||
encodedContext,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const proc = spawn("node", args, {
|
const proc = spawn("node", args, {
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
PATH: process.env.PATH || "",
|
||||||
CLAWSEC_DAST_HARNESS: "1",
|
CLAWSEC_DAST_STATIC_INSPECTION: "1",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -462,31 +403,33 @@ function isObject(value) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {unknown} parsed
|
* @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)) {
|
if (!isObject(parsed)) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: "Harness output is not an object",
|
error: "Harness output is not an object",
|
||||||
messagesCount: 0,
|
staticOnly: false,
|
||||||
messagesCharCount: 0,
|
riskSignals: [],
|
||||||
coreAfter: {},
|
handlerExportDeclared: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const ok = parsed.ok === true;
|
const ok = parsed.ok === true;
|
||||||
const error = typeof parsed.error === "string" ? parsed.error : "";
|
const error = typeof parsed.error === "string" ? parsed.error : "";
|
||||||
const messagesCount = Number(parsed.messages_count ?? 0) || 0;
|
const staticOnly = parsed.static_only === true;
|
||||||
const messagesCharCount = Number(parsed.messages_char_count ?? 0) || 0;
|
const riskSignals = Array.isArray(parsed.risk_signals)
|
||||||
const coreAfter = isObject(parsed.core_after) ? parsed.core_after : {};
|
? parsed.risk_signals.filter((signal) => typeof signal === "string")
|
||||||
|
: [];
|
||||||
|
const handlerExportDeclared = parsed.handler_export_declared === true;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok,
|
ok,
|
||||||
error,
|
error,
|
||||||
messagesCount,
|
staticOnly,
|
||||||
messagesCharCount,
|
riskSignals,
|
||||||
coreAfter,
|
handlerExportDeclared,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,19 +445,6 @@ function slug(input) {
|
|||||||
.slice(0, 60);
|
.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 {Vulnerability[]} bucket
|
||||||
* @param {string} id
|
* @param {string} id
|
||||||
@@ -541,178 +471,74 @@ function pushHookVulnerability(bucket, id, severity, hook, eventKey, title, desc
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {HookDescriptor} hook
|
* @param {HookDescriptor} hook
|
||||||
* @param {string} targetPath
|
* @param {string} _targetPath
|
||||||
* @param {number} timeoutMs
|
* @param {number} timeoutMs
|
||||||
* @returns {Promise<Vulnerability[]>}
|
* @returns {Promise<Vulnerability[]>}
|
||||||
*/
|
*/
|
||||||
async function evaluateHook(hook, targetPath, timeoutMs) {
|
async function evaluateHook(hook, _targetPath, timeoutMs) {
|
||||||
const findings = [];
|
const findings = [];
|
||||||
const invocationTimeoutMs = Math.max(1000, timeoutMs);
|
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) {
|
for (const eventKey of hook.events) {
|
||||||
const safeEvent = buildEvent(eventKey, "safe baseline input", targetPath);
|
if (inspection.timedOut) {
|
||||||
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) {
|
|
||||||
pushHookVulnerability(
|
pushHookVulnerability(
|
||||||
findings,
|
findings,
|
||||||
`DAST-TIMEOUT-${slug(`${hook.name}-${eventKey}`)}`,
|
`DAST-STATIC-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}`)}`,
|
|
||||||
"medium",
|
"medium",
|
||||||
hook,
|
hook,
|
||||||
eventKey,
|
eventKey,
|
||||||
"Hook harness output invalid",
|
"Hook static inspection timed out",
|
||||||
`Could not parse harness output for event '${eventKey}': ${safeResult.parseError}. stderr: ${safeResult.stderr || "(empty)"}`,
|
`Static hook inspection exceeded ${invocationTimeoutMs}ms for event '${eventKey}'. Target code was not executed.`,
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedSafe = normalizeHarnessPayload(safeResult.parsed);
|
if (inspection.parseError) {
|
||||||
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) {
|
|
||||||
pushHookVulnerability(
|
pushHookVulnerability(
|
||||||
findings,
|
findings,
|
||||||
`DAST-MUTATION-${slug(`${hook.name}-${eventKey}`)}`,
|
`DAST-STATIC-HARNESS-${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}`)}`,
|
|
||||||
"medium",
|
"medium",
|
||||||
hook,
|
hook,
|
||||||
eventKey,
|
eventKey,
|
||||||
"Hook output exceeds safe bounds",
|
"Hook static inspection output invalid",
|
||||||
`Hook generated ${normalizedSafe.messagesCount} messages and ${normalizedSafe.messagesCharCount} chars for baseline input. Limits: ${MAX_OUTPUT_MESSAGES} messages / ${MAX_OUTPUT_CHARS} chars.`,
|
`Could not parse static inspection output for event '${eventKey}': ${inspection.parseError}. stderr: ${inspection.stderr || "(empty)"}`,
|
||||||
);
|
);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const maliciousFailures = [];
|
const normalized = normalizeStaticPayload(inspection.parsed);
|
||||||
const maliciousTimeouts = [];
|
if (!normalized.ok || !normalized.staticOnly) {
|
||||||
|
const reason = normalized.error || inspection.stderr || "unknown static inspection error";
|
||||||
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) {
|
|
||||||
pushHookVulnerability(
|
pushHookVulnerability(
|
||||||
findings,
|
findings,
|
||||||
`DAST-MALICIOUS-TIMEOUT-${slug(`${hook.name}-${eventKey}`)}`,
|
`DAST-STATIC-COVERAGE-${slug(`${hook.name}-${eventKey}`)}`,
|
||||||
"high",
|
"info",
|
||||||
hook,
|
hook,
|
||||||
eventKey,
|
eventKey,
|
||||||
"Hook times out on malicious input",
|
"Hook not executed during DAST static inspection",
|
||||||
`Hook exceeded ${invocationTimeoutMs}ms for malicious payloads (${maliciousTimeouts.slice(0, 3).join(", ")}${maliciousTimeouts.length > 3 ? `, +${maliciousTimeouts.length - 3} more` : ""}).`,
|
`DAST did not execute hook code for event '${eventKey}'. Static inspection failed with: ${reason}`,
|
||||||
);
|
);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (maliciousFailures.length > 0) {
|
const signalSuffix = normalized.riskSignals.length > 0
|
||||||
pushHookVulnerability(
|
? ` Static signals observed: ${normalized.riskSignals.join(", ")}.`
|
||||||
findings,
|
: "";
|
||||||
`DAST-MALICIOUS-CRASH-${slug(`${hook.name}-${eventKey}`)}`,
|
const exportSuffix = normalized.handlerExportDeclared
|
||||||
"high",
|
? ""
|
||||||
hook,
|
: " The configured handler export was not obvious from static source inspection.";
|
||||||
eventKey,
|
|
||||||
"Hook crashes on malicious input",
|
pushHookVulnerability(
|
||||||
`Hook raised unhandled errors for malicious payloads. Sample errors: ${maliciousFailures.slice(0, 3).join(" | ")}${maliciousFailures.length > 3 ? ` (+${maliciousFailures.length - 3} more)` : ""}`,
|
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;
|
return findings;
|
||||||
@@ -778,8 +604,6 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { MALICIOUS_PAYLOADS };
|
|
||||||
|
|
||||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||||
main();
|
main();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "clawsec-scanner",
|
"name": "clawsec-scanner",
|
||||||
"version": "0.0.3",
|
"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 DAST hook execution testing for OpenClaw hooks.",
|
"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",
|
"author": "prompt-security",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"homepage": "https://clawsec.prompt.security/",
|
"homepage": "https://clawsec.prompt.security/",
|
||||||
@@ -57,12 +57,12 @@
|
|||||||
{
|
{
|
||||||
"path": "scripts/dast_runner.mjs",
|
"path": "scripts/dast_runner.mjs",
|
||||||
"required": true,
|
"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",
|
"path": "scripts/dast_hook_executor.mjs",
|
||||||
"required": true,
|
"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",
|
"path": "scripts/setup_scanner_hook.mjs",
|
||||||
|
|||||||
@@ -89,8 +89,13 @@ metadata: { "openclaw": { "events": [${eventsLiteral}] } }
|
|||||||
await fs.writeFile(path.join(hookDir, handlerFile), handlerSource, "utf8");
|
await fs.writeFile(path.join(hookDir, handlerFile), handlerSource, "utf8");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function testSafeHookExecutesAndDoesNotReportMisleadingHigh() {
|
async function writeExecutable(filePath, content) {
|
||||||
const testName = "DAST harness: executes real hook and reports no misleading high findings";
|
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();
|
const tmp = await createTempDir();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -125,19 +130,20 @@ export default handler;
|
|||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch(() => false);
|
.catch(() => false);
|
||||||
|
|
||||||
const cleanSummary =
|
const noHighSummary =
|
||||||
result.report?.summary?.critical === 0
|
result.report?.summary?.critical === 0
|
||||||
&& result.report?.summary?.high === 0
|
&& result.report?.summary?.high === 0
|
||||||
&& result.report?.summary?.medium === 0
|
&& result.report?.summary?.medium === 0
|
||||||
&& result.report?.summary?.low === 0
|
&& result.report?.summary?.low === 0;
|
||||||
&& result.report?.summary?.info === 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);
|
pass(testName);
|
||||||
} else {
|
} else {
|
||||||
fail(
|
fail(
|
||||||
testName,
|
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) {
|
} catch (error) {
|
||||||
@@ -147,18 +153,24 @@ export default handler;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function testMaliciousCrashProducesHighFinding() {
|
async function testMaliciousHandlerIsNotExecutedForPayloadChecks() {
|
||||||
const testName = "DAST harness: malicious input crash is reported as high";
|
const testName = "DAST harness: malicious payload checks do not execute hook code";
|
||||||
const tmp = await createTempDir();
|
const tmp = await createTempDir();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const targetPath = path.join(tmp.path, "skill");
|
const targetPath = path.join(tmp.path, "skill");
|
||||||
const hookDir = path.join(targetPath, "hooks", "crashy-hook");
|
const hookDir = path.join(targetPath, "hooks", "crashy-hook");
|
||||||
|
const markerFile = path.join(hookDir, "executed.marker");
|
||||||
|
|
||||||
await writeHookFixture(
|
await writeHookFixture(
|
||||||
hookDir,
|
hookDir,
|
||||||
'"message:preprocessed"',
|
'"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 || "");
|
const payload = String(event?.context?.content || "");
|
||||||
if (payload.includes("<script>")) {
|
if (payload.includes("<script>")) {
|
||||||
throw new Error("Unhandled payload path");
|
throw new Error("Unhandled payload path");
|
||||||
@@ -170,16 +182,21 @@ export default handler;
|
|||||||
);
|
);
|
||||||
|
|
||||||
const result = await runDast(targetPath, 2500);
|
const result = await runDast(targetPath, 2500);
|
||||||
const hasHigh = Number(result.report?.summary?.high || 0) > 0;
|
const markerExists = await fs
|
||||||
const hasCrashFinding = Array.isArray(result.report?.vulnerabilities)
|
.access(markerFile)
|
||||||
&& result.report.vulnerabilities.some((v) => String(v.id || "").includes("DAST-MALICIOUS-CRASH"));
|
.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);
|
pass(testName);
|
||||||
} else {
|
} else {
|
||||||
fail(
|
fail(
|
||||||
testName,
|
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) {
|
} catch (error) {
|
||||||
@@ -189,8 +206,8 @@ export default handler;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function testMissingTypeScriptCompilerIsCoverageInfo() {
|
async function testTypeScriptHookIsStaticallyInspectedWithoutCompiler() {
|
||||||
const testName = "DAST harness: missing TypeScript compiler reports coverage info, not high";
|
const testName = "DAST harness: TypeScript hooks are statically inspected without compiler execution";
|
||||||
const tmp = await createTempDir();
|
const tmp = await createTempDir();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -220,7 +237,7 @@ export default handler;
|
|||||||
const noHigh = Number(result.report?.summary?.high || 0) === 0
|
const noHigh = Number(result.report?.summary?.high || 0) === 0
|
||||||
&& Number(result.report?.summary?.critical || 0) === 0;
|
&& Number(result.report?.summary?.critical || 0) === 0;
|
||||||
const hasCoverageInfo = Array.isArray(result.report?.vulnerabilities)
|
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;
|
const hasInfoCount = Number(result.report?.summary?.info || 0) > 0;
|
||||||
|
|
||||||
if (result.code === 0 && noHigh && hasCoverageInfo && hasInfoCount) {
|
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() {
|
async function main() {
|
||||||
await testSafeHookExecutesAndDoesNotReportMisleadingHigh();
|
await testSafeHookIsInspectedWithoutExecution();
|
||||||
await testMaliciousCrashProducesHighFinding();
|
await testMaliciousHandlerIsNotExecutedForPayloadChecks();
|
||||||
await testMissingTypeScriptCompilerIsCoverageInfo();
|
await testTypeScriptHookIsStaticallyInspectedWithoutCompiler();
|
||||||
|
await testStaticInspectionRunsOncePerHook();
|
||||||
|
|
||||||
report();
|
report();
|
||||||
exitWithResults();
|
exitWithResults();
|
||||||
|
|||||||
Reference in New Issue
Block a user