From 13585f1092c92fa3f20ffe0d756e40c5720b0de5 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 15 May 2026 08:06:26 -0400 Subject: [PATCH] feat: add platform and supply-chain audit commands (#1926) --- .github/workflows/release.yml | 3 + .github/workflows/reusable-release.yml | 3 + package.json | 3 + scripts/ci/scan-supply-chain-iocs.js | 29 +- scripts/ecc.js | 12 + scripts/platform-audit.js | 630 ++++++++++++++++++++++ tests/ci/scan-supply-chain-iocs.test.js | 16 +- tests/scripts/ecc.test.js | 24 + tests/scripts/npm-publish-surface.test.js | 4 + tests/scripts/platform-audit.test.js | 328 +++++++++++ 10 files changed, 1049 insertions(+), 3 deletions(-) create mode 100644 scripts/platform-audit.js create mode 100644 tests/scripts/platform-audit.test.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 50b0cc02..7ebda383 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,6 +29,9 @@ jobs: - name: Install dependencies run: npm ci --ignore-scripts + - name: Run supply-chain IOC scan + run: npm run security:ioc-scan + - name: Verify OpenCode package payload run: node tests/scripts/build-opencode.test.js diff --git a/.github/workflows/reusable-release.yml b/.github/workflows/reusable-release.yml index 901ac526..62839e74 100644 --- a/.github/workflows/reusable-release.yml +++ b/.github/workflows/reusable-release.yml @@ -53,6 +53,9 @@ jobs: - name: Install dependencies run: npm ci --ignore-scripts + - name: Run supply-chain IOC scan + run: npm run security:ioc-scan + - name: Verify OpenCode package payload run: node tests/scripts/build-opencode.test.js diff --git a/package.json b/package.json index 8c30dca4..207c81bc 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "rules/", "schemas/", "scripts/catalog.js", + "scripts/ci/scan-supply-chain-iocs.js", "scripts/consult.js", "scripts/auto-update.js", "scripts/claw.js", @@ -74,6 +75,7 @@ "scripts/harness-adapter-compliance.js", "scripts/harness-audit.js", "scripts/observability-readiness.js", + "scripts/platform-audit.js", "scripts/hooks/", "scripts/install-apply.js", "scripts/install-plan.js", @@ -293,6 +295,7 @@ "harness:adapters": "node scripts/harness-adapter-compliance.js", "harness:audit": "node scripts/harness-audit.js", "observability:ready": "node scripts/observability-readiness.js", + "platform:audit": "node scripts/platform-audit.js", "security:ioc-scan": "node scripts/ci/scan-supply-chain-iocs.js", "claw": "node scripts/claw.js", "orchestrate:status": "node scripts/orchestration-status.js", diff --git a/scripts/ci/scan-supply-chain-iocs.js b/scripts/ci/scan-supply-chain-iocs.js index 7394288b..15e2e6e6 100755 --- a/scripts/ci/scan-supply-chain-iocs.js +++ b/scripts/ci/scan-supply-chain-iocs.js @@ -253,7 +253,6 @@ const CRITICAL_TEXT_INDICATORS = [ 'seed2.getsession.org', 'seed3.getsession.org', 'signalservice', - 'snode', 'git-tanstack.com', 'litter.catbox.moe/h8nc9u.js', 'litter.catbox.moe/7rrc6l.mjs', @@ -620,7 +619,9 @@ function parseArgs(argv) { const options = {}; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; - if (arg === '--root') { + if (arg === '--help' || arg === '-h') { + options.help = true; + } else if (arg === '--root') { options.rootDir = argv[++i]; } else if (arg === '--home') { options.home = true; @@ -636,6 +637,26 @@ function parseArgs(argv) { return options; } +function printHelp() { + console.log(`Usage: node scripts/ci/scan-supply-chain-iocs.js [options] + +Scan dependency manifests, lockfiles, installed package payloads, and AI-tool +persistence paths for active supply-chain IOC markers. + +Options: + --root Directory to scan (default: repo root) + --home Also scan user-level Claude, VS Code, LaunchAgent, systemd, + and /tmp persistence targets + --home-dir Home directory to use with --home + --json Emit JSON instead of text + --help, -h Show this help + +Examples: + node scripts/ci/scan-supply-chain-iocs.js --home + node scripts/ci/scan-supply-chain-iocs.js --root /path/to/project --json +`); +} + function printReport(result, json = false) { if (json) { console.log(JSON.stringify(result, null, 2)); @@ -658,6 +679,10 @@ function printReport(result, json = false) { if (require.main === module) { try { const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + process.exit(0); + } const result = scanSupplyChainIocs(options); printReport(result, options.json); process.exit(result.findings.length > 0 ? 1 : 0); diff --git a/scripts/ecc.js b/scripts/ecc.js index 5fbeac97..7f5c41d0 100755 --- a/scripts/ecc.js +++ b/scripts/ecc.js @@ -45,6 +45,14 @@ const COMMANDS = { script: 'status.js', description: 'Query the ECC SQLite state store status summary', }, + 'platform-audit': { + script: 'platform-audit.js', + description: 'Audit GitHub queues, discussions, roadmap, release, and security evidence', + }, + 'security-ioc-scan': { + script: 'ci/scan-supply-chain-iocs.js', + description: 'Scan dependency and AI-tool persistence surfaces for active supply-chain IOCs', + }, sessions: { script: 'sessions-cli.js', description: 'List or inspect ECC sessions from the SQLite state store', @@ -77,6 +85,8 @@ const PRIMARY_COMMANDS = [ 'repair', 'auto-update', 'status', + 'platform-audit', + 'security-ioc-scan', 'sessions', 'work-items', 'session-inspect', @@ -115,6 +125,8 @@ Examples: ecc status --json ecc status --exit-code ecc status --markdown --write status.md + ecc platform-audit --json --allow-untracked docs/drafts/ + ecc security-ioc-scan --home ecc sessions ecc sessions session-active --json ecc work-items upsert linear-ecc-20 --source linear --source-id ECC-20 --title "Review control-plane contract" --status blocked diff --git a/scripts/platform-audit.js b/scripts/platform-audit.js new file mode 100644 index 00000000..1b91e251 --- /dev/null +++ b/scripts/platform-audit.js @@ -0,0 +1,630 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const SCHEMA_VERSION = 'ecc.platform-audit.v1'; +const DEFAULT_REPOS = Object.freeze([ + 'affaan-m/everything-claude-code', + 'affaan-m/agentshield', + 'affaan-m/JARVIS', + 'ECC-Tools/ECC-Tools', + 'ECC-Tools/ECC-website', +]); +const DEFAULT_THRESHOLDS = Object.freeze({ + maxOpenPrs: 20, + maxOpenIssues: 20, + maxDirtyFiles: 0, +}); +const MAINTAINER_ASSOCIATIONS = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); +const DISCUSSION_QUERY = 'query($owner: String!, $name: String!, $first: Int!) { repository(owner: $owner, name: $name) { hasDiscussionsEnabled discussions(first: $first, orderBy: {field: UPDATED_AT, direction: DESC}) { totalCount nodes { number title url updatedAt authorAssociation comments(first: 20) { nodes { authorAssociation } } } } } }'; + +function usage() { + console.log([ + 'Usage: node scripts/platform-audit.js [options]', + '', + 'Operator readiness audit for ECC queue, discussion, roadmap, release, and security evidence.', + '', + 'Options:', + ' --format Output format (default: text)', + ' --root Repository root to inspect (default: cwd)', + ' --repo GitHub repo to inspect; repeatable', + ' --skip-github Skip live GitHub queue/discussion checks', + ' --max-open-prs Fail when open PR count is above n (default: 20)', + ' --max-open-issues Fail when open issue count is above n (default: 20)', + ' --max-dirty-files Fail when blocking dirty file count is above n (default: 0)', + ' --allow-untracked Ignore untracked files under path; repeatable', + ' --use-env-github-token Keep GITHUB_TOKEN when invoking gh', + ' --exit-code Return 2 when the audit is not ready', + ' --help, -h Show this help', + ].join('\n')); +} + +function readValue(args, index, flagName) { + const value = args[index + 1]; + if (!value || value.startsWith('--')) { + throw new Error(`${flagName} requires a value`); + } + return value; +} + +function parseIntegerFlag(value, flagName) { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new Error(`Invalid ${flagName}: ${value}`); + } + return parsed; +} + +function parseArgs(argv) { + const args = argv.slice(2); + const parsed = { + allowUntracked: [], + exitCode: false, + format: 'text', + help: false, + repos: [], + root: path.resolve(process.cwd()), + skipGithub: false, + thresholds: { ...DEFAULT_THRESHOLDS }, + useEnvGithubToken: false, + }; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + + if (arg === '--help' || arg === '-h') { + parsed.help = true; + continue; + } + + if (arg === '--format') { + parsed.format = readValue(args, index, arg).toLowerCase(); + index += 1; + continue; + } + + if (arg.startsWith('--format=')) { + parsed.format = arg.slice('--format='.length).toLowerCase(); + continue; + } + + if (arg === '--root') { + parsed.root = path.resolve(readValue(args, index, arg)); + index += 1; + continue; + } + + if (arg.startsWith('--root=')) { + parsed.root = path.resolve(arg.slice('--root='.length)); + continue; + } + + if (arg === '--repo') { + parsed.repos.push(readValue(args, index, arg)); + index += 1; + continue; + } + + if (arg.startsWith('--repo=')) { + parsed.repos.push(arg.slice('--repo='.length)); + continue; + } + + if (arg === '--skip-github') { + parsed.skipGithub = true; + continue; + } + + if (arg === '--allow-untracked') { + parsed.allowUntracked.push(readValue(args, index, arg)); + index += 1; + continue; + } + + if (arg.startsWith('--allow-untracked=')) { + parsed.allowUntracked.push(arg.slice('--allow-untracked='.length)); + continue; + } + + if (arg === '--max-open-prs') { + parsed.thresholds.maxOpenPrs = parseIntegerFlag(readValue(args, index, arg), arg); + index += 1; + continue; + } + + if (arg.startsWith('--max-open-prs=')) { + parsed.thresholds.maxOpenPrs = parseIntegerFlag(arg.slice('--max-open-prs='.length), '--max-open-prs'); + continue; + } + + if (arg === '--max-open-issues') { + parsed.thresholds.maxOpenIssues = parseIntegerFlag(readValue(args, index, arg), arg); + index += 1; + continue; + } + + if (arg.startsWith('--max-open-issues=')) { + parsed.thresholds.maxOpenIssues = parseIntegerFlag(arg.slice('--max-open-issues='.length), '--max-open-issues'); + continue; + } + + if (arg === '--max-dirty-files') { + parsed.thresholds.maxDirtyFiles = parseIntegerFlag(readValue(args, index, arg), arg); + index += 1; + continue; + } + + if (arg.startsWith('--max-dirty-files=')) { + parsed.thresholds.maxDirtyFiles = parseIntegerFlag(arg.slice('--max-dirty-files='.length), '--max-dirty-files'); + continue; + } + + if (arg === '--use-env-github-token') { + parsed.useEnvGithubToken = true; + continue; + } + + if (arg === '--exit-code') { + parsed.exitCode = true; + continue; + } + + throw new Error(`Unknown argument: ${arg}`); + } + + if (!['text', 'json'].includes(parsed.format)) { + throw new Error(`Invalid format: ${parsed.format}. Use text or json.`); + } + + parsed.allowUntracked = parsed.allowUntracked.map(normalizeRelativePrefix); + + return parsed; +} + +function normalizeRelativePrefix(value) { + return String(value || '') + .replace(/\\/g, '/') + .replace(/^\.\/+/, '') + .replace(/\/+$/, '') + (String(value || '').endsWith('/') ? '/' : ''); +} + +function runCommand(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd, + env: options.env || process.env, + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + }); + + if (result.error) { + throw new Error(`${command} ${args.join(' ')} failed: ${result.error.message}`); + } + + if (result.status !== 0) { + throw new Error(`${command} ${args.join(' ')} failed: ${(result.stderr || result.stdout || '').trim()}`); + } + + return result.stdout || ''; +} + +function runGhJson(args, options = {}) { + const shimPath = process.env.ECC_GH_SHIM; + const command = shimPath ? process.execPath : 'gh'; + const commandArgs = shimPath ? [shimPath, ...args] : args; + const env = { ...process.env }; + + if (!options.useEnvGithubToken) { + delete env.GITHUB_TOKEN; + } + + const stdout = runCommand(command, commandArgs, { env }); + try { + return JSON.parse(stdout || 'null'); + } catch (error) { + throw new Error(`gh ${args.join(' ')} returned invalid JSON: ${error.message}`); + } +} + +function readText(rootDir, relativePath) { + try { + return fs.readFileSync(path.join(rootDir, relativePath), 'utf8'); + } catch (_error) { + return ''; + } +} + +function safeParseJson(text) { + if (!text || !text.trim()) { + return null; + } + + try { + return JSON.parse(text); + } catch (_error) { + return null; + } +} + +function includesAll(text, needles) { + return needles.every(needle => text.includes(needle)); +} + +function buildCheck(id, status, summary, details = {}) { + return { id, status, summary, ...details }; +} + +function parseGitStatus(output) { + const lines = output.split(/\r?\n/).filter(Boolean); + const branchLine = lines[0] || ''; + const dirtyLines = lines.slice(1); + return { + branch: branchLine.replace(/^##\s*/, '') || null, + dirtyLines, + }; +} + +function isAllowedUntracked(statusLine, allowUntracked) { + if (!statusLine.startsWith('?? ')) { + return false; + } + + const relativePath = statusLine.slice(3).replace(/\\/g, '/'); + return allowUntracked.some(prefix => relativePath === prefix || relativePath.startsWith(prefix)); +} + +function inspectGit(rootDir, options) { + try { + const parsed = parseGitStatus(runCommand('git', ['status', '--short', '--branch'], { cwd: rootDir })); + const ignoredDirty = parsed.dirtyLines.filter(line => isAllowedUntracked(line, options.allowUntracked)); + const blockingDirty = parsed.dirtyLines.filter(line => !isAllowedUntracked(line, options.allowUntracked)); + + return { + available: true, + branch: parsed.branch, + dirtyLines: parsed.dirtyLines, + ignoredDirty, + blockingDirty, + blockingDirtyCount: blockingDirty.length, + }; + } catch (error) { + return { + available: false, + error: error.message, + branch: null, + dirtyLines: [], + ignoredDirty: [], + blockingDirty: [], + blockingDirtyCount: 0, + }; + } +} + +function discussionNeedsMaintainerTouch(discussion) { + if (MAINTAINER_ASSOCIATIONS.has(discussion.authorAssociation)) { + return false; + } + + const comments = discussion.comments && Array.isArray(discussion.comments.nodes) + ? discussion.comments.nodes + : []; + return !comments.some(comment => MAINTAINER_ASSOCIATIONS.has(comment.authorAssociation)); +} + +function splitRepo(repo) { + const [owner, name] = String(repo || '').split('/'); + if (!owner || !name) { + throw new Error(`Invalid repo: ${repo}`); + } + return { owner, name }; +} + +function fetchDiscussionSummary(repo, options) { + const { owner, name } = splitRepo(repo); + const payload = runGhJson([ + 'api', + 'graphql', + '-f', + `owner=${owner}`, + '-f', + `name=${name}`, + '-F', + 'first=100', + '-f', + `query=${DISCUSSION_QUERY}`, + ], options); + const repository = payload && payload.data && payload.data.repository; + const discussions = repository && repository.discussions; + const nodes = discussions && Array.isArray(discussions.nodes) ? discussions.nodes : []; + const needingTouch = nodes.filter(discussionNeedsMaintainerTouch); + + return { + enabled: Boolean(repository && repository.hasDiscussionsEnabled), + totalCount: discussions && Number.isFinite(discussions.totalCount) ? discussions.totalCount : 0, + sampledCount: nodes.length, + needingMaintainerTouch: needingTouch.map(discussion => ({ + number: discussion.number, + title: discussion.title, + url: discussion.url, + updatedAt: discussion.updatedAt, + })), + }; +} + +function fetchGithubRepo(repo, options) { + const prs = runGhJson([ + 'pr', + 'list', + '--repo', + repo, + '--state', + 'open', + '--json', + 'number,title,isDraft,mergeStateStatus,updatedAt,url,author', + ], options); + const issues = runGhJson([ + 'issue', + 'list', + '--repo', + repo, + '--state', + 'open', + '--json', + 'number,title,updatedAt,url,author,labels', + ], options); + const discussionSummary = fetchDiscussionSummary(repo, options); + + return { + repo, + openPrs: Array.isArray(prs) ? prs.length : 0, + openIssues: Array.isArray(issues) ? issues.length : 0, + discussions: discussionSummary, + dirtyPrs: (Array.isArray(prs) ? prs : []).filter(pr => pr.mergeStateStatus === 'DIRTY').map(pr => ({ + number: pr.number, + title: pr.title, + url: pr.url, + })), + }; +} + +function buildGithubReport(options) { + const repos = options.repos.length > 0 ? options.repos : DEFAULT_REPOS; + + if (options.skipGithub) { + return { + skipped: true, + repos: repos.map(repo => ({ repo, skipped: true })), + totals: { + openPrs: 0, + openIssues: 0, + discussionsNeedingMaintainerTouch: 0, + dirtyPrs: 0, + errors: 0, + }, + }; + } + + const repoReports = repos.map(repo => { + try { + return fetchGithubRepo(repo, options); + } catch (error) { + return { + repo, + error: error.message, + openPrs: 0, + openIssues: 0, + discussions: { + enabled: false, + totalCount: 0, + sampledCount: 0, + needingMaintainerTouch: [], + }, + dirtyPrs: [], + }; + } + }); + + return { + skipped: false, + repos: repoReports, + totals: { + openPrs: repoReports.reduce((sum, repo) => sum + repo.openPrs, 0), + openIssues: repoReports.reduce((sum, repo) => sum + repo.openIssues, 0), + discussionsNeedingMaintainerTouch: repoReports.reduce((sum, repo) => sum + repo.discussions.needingMaintainerTouch.length, 0), + dirtyPrs: repoReports.reduce((sum, repo) => sum + repo.dirtyPrs.length, 0), + errors: repoReports.filter(repo => repo.error).length, + }, + }; +} + +function buildLocalEvidenceChecks(rootDir) { + const packageJson = safeParseJson(readText(rootDir, 'package.json')) || {}; + const packageScripts = packageJson.scripts || {}; + const roadmap = readText(rootDir, 'docs/ECC-2.0-GA-ROADMAP.md'); + const progressSync = readText(rootDir, 'docs/architecture/progress-sync-contract.md'); + const supplyChain = readText(rootDir, 'docs/security/supply-chain-incident-response.md'); + const evidence = readText(rootDir, 'docs/releases/2.0.0-rc.1/publication-evidence-2026-05-15.md'); + + return [ + buildCheck( + 'platform-audit-cli-surface', + packageScripts['platform:audit'] === 'node scripts/platform-audit.js' ? 'pass' : 'fail', + 'package.json exposes the platform audit command', + { fix: 'Add "platform:audit": "node scripts/platform-audit.js" to package.json.' } + ), + buildCheck( + 'roadmap-linear-mirror', + includesAll(roadmap, ['linear.app/itomarkets/project/ecc-platform-roadmap', 'ITO-44', 'ITO-59']) ? 'pass' : 'fail', + 'repo roadmap mirrors the Linear roadmap and security/operator lanes', + { path: 'docs/ECC-2.0-GA-ROADMAP.md' } + ), + buildCheck( + 'progress-sync-contract', + includesAll(progressSync, ['GitHub PRs/issues/discussions', 'Linear project', 'local handoff', 'repo roadmap', 'scripts/work-items.js']) ? 'pass' : 'fail', + 'progress sync contract names GitHub, Linear, handoff, roadmap, and work-items surfaces', + { path: 'docs/architecture/progress-sync-contract.md' } + ), + buildCheck( + 'supply-chain-runbook', + includesAll(supplyChain, ['TanStack', 'Mini Shai-Hulud', 'node-ipc', 'scan-supply-chain-iocs.js']) ? 'pass' : 'fail', + 'supply-chain runbook covers the current TanStack/Mini Shai-Hulud/node-ipc scanner lane', + { path: 'docs/security/supply-chain-incident-response.md' } + ), + buildCheck( + 'release-evidence-current', + includesAll(evidence, ['TanStack', 'Mini Shai-Hulud', 'Node IPC follow-up', 'node-ipc', 'IOC scan']) ? 'pass' : 'fail', + 'rc.1 evidence includes current supply-chain verification artifacts', + { path: 'docs/releases/2.0.0-rc.1/publication-evidence-2026-05-15.md' } + ), + ]; +} + +function buildReport(options) { + const rootDir = path.resolve(options.root); + const git = inspectGit(rootDir, options); + const github = buildGithubReport(options); + const checks = []; + + checks.push(buildCheck( + 'git-worktree-blockers', + !git.available ? 'warn' : (git.blockingDirtyCount <= options.thresholds.maxDirtyFiles ? 'pass' : 'fail'), + !git.available + ? 'git status is unavailable for this root' + : `blocking dirty files: ${git.blockingDirtyCount}`, + { + branch: git.branch, + ignoredDirtyCount: git.ignoredDirty.length, + blockingDirty: git.blockingDirty, + fix: 'Commit, stash, or explicitly allow unrelated untracked files before claiming release readiness.', + } + )); + + checks.push(buildCheck( + 'github-fetch', + github.skipped ? 'warn' : (github.totals.errors === 0 ? 'pass' : 'fail'), + github.skipped ? 'live GitHub checks skipped' : `GitHub fetch errors: ${github.totals.errors}`, + { fix: 'Re-run with working gh authentication or ECC_GH_SHIM for deterministic tests.' } + )); + + checks.push(buildCheck( + 'github-open-pr-budget', + github.totals.openPrs <= options.thresholds.maxOpenPrs ? 'pass' : 'fail', + `open PRs: ${github.totals.openPrs}/${options.thresholds.maxOpenPrs}`, + { fix: 'Triage, merge, close, or attach open PRs to roadmap issues until under budget.' } + )); + + checks.push(buildCheck( + 'github-open-issue-budget', + github.totals.openIssues <= options.thresholds.maxOpenIssues ? 'pass' : 'fail', + `open issues: ${github.totals.openIssues}/${options.thresholds.maxOpenIssues}`, + { fix: 'Triage, close, or attach open issues to Linear/project lanes until under budget.' } + )); + + checks.push(buildCheck( + 'github-discussion-touch', + github.totals.discussionsNeedingMaintainerTouch === 0 ? 'pass' : 'fail', + `discussions needing maintainer touch: ${github.totals.discussionsNeedingMaintainerTouch}`, + { fix: 'Respond to or route discussions without maintainer touch before marking the queue current.' } + )); + + checks.push(buildCheck( + 'github-conflict-queue', + github.totals.dirtyPrs === 0 ? 'pass' : 'fail', + `conflicting open PRs: ${github.totals.dirtyPrs}`, + { fix: 'Update, rebase, salvage, or close conflicting open PRs.' } + )); + + checks.push(...buildLocalEvidenceChecks(rootDir)); + + const topActions = checks + .filter(check => check.status === 'fail') + .map(check => ({ + id: check.id, + summary: check.summary, + fix: check.fix || 'Review and remediate this failed check.', + })); + + return { + schema_version: SCHEMA_VERSION, + generatedAt: new Date().toISOString(), + root: rootDir, + ready: topActions.length === 0, + thresholds: options.thresholds, + git, + github, + checks, + top_actions: topActions, + }; +} + +function renderText(report) { + const lines = [ + `ECC Platform Audit: ${report.ready ? 'ready' : 'attention required'}`, + `Generated: ${report.generatedAt}`, + `Root: ${report.root}`, + '', + `Git: ${report.git.available ? report.git.branch : 'unavailable'}`, + `Blocking dirty files: ${report.git.blockingDirtyCount}`, + `Ignored dirty files: ${report.git.ignoredDirty.length}`, + '', + `GitHub skipped: ${report.github.skipped ? 'yes' : 'no'}`, + `Open PRs: ${report.github.totals.openPrs}/${report.thresholds.maxOpenPrs}`, + `Open issues: ${report.github.totals.openIssues}/${report.thresholds.maxOpenIssues}`, + `Discussions needing maintainer touch: ${report.github.totals.discussionsNeedingMaintainerTouch}`, + `Conflicting open PRs: ${report.github.totals.dirtyPrs}`, + '', + 'Checks:', + ]; + + for (const check of report.checks) { + lines.push(` ${check.status.toUpperCase()} ${check.id}: ${check.summary}`); + } + + lines.push('', 'Top actions:'); + if (report.top_actions.length === 0) { + lines.push(' none'); + } else { + for (const action of report.top_actions) { + lines.push(` - ${action.id}: ${action.fix}`); + } + } + + return `${lines.join('\n')}\n`; +} + +function main() { + try { + const options = parseArgs(process.argv); + if (options.help) { + usage(); + return; + } + + const report = buildReport(options); + const output = options.format === 'json' + ? `${JSON.stringify(report, null, 2)}\n` + : renderText(report); + process.stdout.write(output); + + if (options.exitCode && !report.ready) { + process.exitCode = 2; + } + } catch (error) { + console.error(`Error: ${error.message}`); + process.exit(1); + } +} + +if (require.main === module) { + main(); +} + +module.exports = { + buildReport, + parseArgs, + renderText, + runGhJson, +}; diff --git a/tests/ci/scan-supply-chain-iocs.test.js b/tests/ci/scan-supply-chain-iocs.test.js index 2bbd48d7..e1768313 100755 --- a/tests/ci/scan-supply-chain-iocs.test.js +++ b/tests/ci/scan-supply-chain-iocs.test.js @@ -154,6 +154,21 @@ function run() { }); })) passed++; else failed++; + if (test('does not flag benign substrings in clean package scripts', () => { + withFixture({ + 'node_modules/uuid/package.json': JSON.stringify({ + name: 'uuid', + version: '9.0.1', + scripts: { + test: 'BABEL_ENV=commonjsNode node --throw-deprecation node_modules/.bin/jest test/unit/', + }, + }, null, 2), + }, rootDir => { + const result = scanSupplyChainIocs({ rootDir }); + assert.deepStrictEqual(result.findings, []); + }); + })) passed++; else failed++; + if (test('rejects malicious optional dependency markers', () => { withFixture({ 'package-lock.json': JSON.stringify({ @@ -241,7 +256,6 @@ function run() { assert.ok(indicators.includes('claude@users.noreply.github.com')); assert.ok(indicators.includes('dependabout/')); assert.ok(indicators.includes('signalservice')); - assert.ok(indicators.includes('snode')); }); })) passed++; else failed++; diff --git a/tests/scripts/ecc.test.js b/tests/scripts/ecc.test.js index c380623a..7d3d5ad8 100644 --- a/tests/scripts/ecc.test.js +++ b/tests/scripts/ecc.test.js @@ -72,6 +72,8 @@ function main() { assert.match(result.stdout, /consult/); assert.match(result.stdout, /loop-status/); assert.match(result.stdout, /work-items/); + assert.match(result.stdout, /platform-audit/); + assert.match(result.stdout, /security-ioc-scan/); }], ['delegates explicit install command', () => { const result = runCli(['install', '--dry-run', '--json', 'typescript']); @@ -207,6 +209,28 @@ function main() { assert.strictEqual(result.status, 0, result.stderr); assert.match(result.stdout, /node scripts\/work-items\.js upsert/); }], + ['supports help for the platform-audit subcommand', () => { + const result = runCli(['help', 'platform-audit']); + assert.strictEqual(result.status, 0, result.stderr); + assert.match(result.stdout, /Usage: node scripts\/platform-audit\.js/); + }], + ['supports help for the security-ioc-scan subcommand', () => { + const result = runCli(['help', 'security-ioc-scan']); + assert.strictEqual(result.status, 0, result.stderr); + assert.match(result.stdout, /Usage: node scripts\/ci\/scan-supply-chain-iocs\.js/); + }], + ['delegates security-ioc-scan command', () => { + const projectRoot = createTempDir('ecc-cli-ioc-scan-'); + fs.writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ dependencies: { leftpad: '1.0.0' } }, null, 2) + ); + + const result = runCli(['security-ioc-scan', '--root', projectRoot, '--json']); + assert.strictEqual(result.status, 0, result.stderr); + const payload = parseJson(result.stdout); + assert.deepStrictEqual(payload.findings, []); + }], ['fails on unknown commands instead of treating them as installs', () => { const result = runCli(['bogus']); assert.strictEqual(result.status, 1); diff --git a/tests/scripts/npm-publish-surface.test.js b/tests/scripts/npm-publish-surface.test.js index 07c28c78..6f9f53d1 100644 --- a/tests/scripts/npm-publish-surface.test.js +++ b/tests/scripts/npm-publish-surface.test.js @@ -43,6 +43,7 @@ function buildExpectedPublishPaths(repoRoot) { "manifests", "scripts/ecc.js", "scripts/catalog.js", + "scripts/ci/scan-supply-chain-iocs.js", "scripts/consult.js", "scripts/claw.js", "scripts/doctor.js", @@ -54,6 +55,7 @@ function buildExpectedPublishPaths(repoRoot) { "scripts/list-installed.js", "scripts/loop-status.js", "scripts/observability-readiness.js", + "scripts/platform-audit.js", "scripts/skill-create-output.js", "scripts/repair.js", "scripts/harness-adapter-compliance.js", @@ -119,8 +121,10 @@ function main() { for (const requiredPath of [ "scripts/catalog.js", + "scripts/ci/scan-supply-chain-iocs.js", "scripts/consult.js", "scripts/work-items.js", + "scripts/platform-audit.js", ".gemini/GEMINI.md", ".qwen/QWEN.md", ".claude-plugin/plugin.json", diff --git a/tests/scripts/platform-audit.test.js b/tests/scripts/platform-audit.test.js new file mode 100644 index 00000000..573c2e3d --- /dev/null +++ b/tests/scripts/platform-audit.test.js @@ -0,0 +1,328 @@ +/** + * Tests for scripts/platform-audit.js + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFileSync, spawnSync } = require('child_process'); + +const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'platform-audit.js'); + +function createTempDir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function cleanup(dirPath) { + fs.rmSync(dirPath, { recursive: true, force: true }); +} + +function writeFile(rootDir, relativePath, content) { + const targetPath = path.join(rootDir, relativePath); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync(targetPath, content); +} + +function seedRepo(rootDir, overrides = {}) { + const files = { + 'package.json': JSON.stringify({ + name: 'everything-claude-code', + scripts: { + 'platform:audit': 'node scripts/platform-audit.js', + 'observability:ready': 'node scripts/observability-readiness.js', + 'security:ioc-scan': 'node scripts/ci/scan-supply-chain-iocs.js', + 'harness:audit': 'node scripts/harness-audit.js' + } + }, null, 2), + 'docs/ECC-2.0-GA-ROADMAP.md': [ + 'ECC Platform Roadmap', + 'https://linear.app/itomarkets/project/ecc-platform-roadmap-52b328ee03e1', + 'ITO-44', + 'ITO-59' + ].join('\n'), + 'docs/architecture/progress-sync-contract.md': [ + 'GitHub PRs/issues/discussions', + 'Linear project', + 'local handoff', + 'repo roadmap', + 'scripts/work-items.js' + ].join('\n'), + 'docs/security/supply-chain-incident-response.md': [ + 'TanStack', + 'Mini Shai-Hulud', + 'node-ipc', + 'scan-supply-chain-iocs.js' + ].join('\n'), + 'docs/releases/2.0.0-rc.1/publication-evidence-2026-05-15.md': [ + 'TanStack', + 'Mini Shai-Hulud', + 'Node IPC follow-up', + 'node-ipc', + 'IOC scan' + ].join('\n') + }; + + for (const [relativePath, content] of Object.entries({ ...files, ...overrides })) { + if (content === null) { + continue; + } + writeFile(rootDir, relativePath, content); + } +} + +function writeGhShim(rootDir, responses) { + const shimPath = path.join(rootDir, 'gh-shim.js'); + fs.writeFileSync(shimPath, ` +const responses = ${JSON.stringify(responses)}; +const args = process.argv.slice(2); +const key = args.join(' '); +if (process.env.GITHUB_TOKEN) { + console.error('GITHUB_TOKEN should be unset by default'); + process.exit(42); +} +if (!Object.prototype.hasOwnProperty.call(responses, key)) { + console.error('Unexpected gh args: ' + key); + process.exit(3); +} +process.stdout.write(JSON.stringify(responses[key])); +`); + return shimPath; +} + +function run(args = [], options = {}) { + const env = { + ...process.env, + ...(options.env || {}) + }; + + return execFileSync('node', [SCRIPT, ...args], { + cwd: options.cwd || path.join(__dirname, '..', '..'), + env, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 10000 + }); +} + +function runProcess(args = [], options = {}) { + const env = { + ...process.env, + ...(options.env || {}) + }; + + return spawnSync('node', [SCRIPT, ...args], { + cwd: options.cwd || path.join(__dirname, '..', '..'), + env, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 10000 + }); +} + +function test(name, fn) { + try { + fn(); + console.log(` PASS ${name}`); + return true; + } catch (error) { + console.log(` FAIL ${name}`); + console.log(` Error: ${error.message}`); + return false; + } +} + +function runTests() { + console.log('\n=== Testing platform-audit.js ===\n'); + + let passed = 0; + let failed = 0; + + if (test('parseArgs accepts supported flags and rejects invalid values', () => { + const { parseArgs } = require(SCRIPT); + const rootDir = createTempDir('platform-audit-args-'); + + try { + const parsed = parseArgs([ + 'node', + 'script', + '--format=json', + `--root=${rootDir}`, + '--repo', + 'affaan-m/everything-claude-code', + '--max-open-prs', + '5', + '--max-open-issues', + '6', + '--allow-untracked', + 'docs/drafts/' + ]); + + assert.strictEqual(parsed.format, 'json'); + assert.strictEqual(parsed.root, path.resolve(rootDir)); + assert.deepStrictEqual(parsed.repos, ['affaan-m/everything-claude-code']); + assert.strictEqual(parsed.thresholds.maxOpenPrs, 5); + assert.strictEqual(parsed.thresholds.maxOpenIssues, 6); + assert.deepStrictEqual(parsed.allowUntracked, ['docs/drafts/']); + + assert.throws(() => parseArgs(['node', 'script', '--format', 'xml']), /Invalid format/); + assert.throws(() => parseArgs(['node', 'script', '--repo']), /--repo requires a value/); + assert.throws(() => parseArgs(['node', 'script', '--max-open-prs', 'x']), /Invalid --max-open-prs/); + assert.throws(() => parseArgs(['node', 'script', '--unknown']), /Unknown argument/); + } finally { + cleanup(rootDir); + } + })) passed++; else failed++; + + if (test('skip-github report checks local release and security evidence', () => { + const projectRoot = createTempDir('platform-audit-local-'); + + try { + seedRepo(projectRoot); + const parsed = JSON.parse(run(['--format=json', `--root=${projectRoot}`, '--skip-github'], { cwd: projectRoot })); + + assert.strictEqual(parsed.schema_version, 'ecc.platform-audit.v1'); + assert.strictEqual(parsed.ready, true); + assert.strictEqual(parsed.github.skipped, true); + assert.ok(parsed.checks.some(check => check.id === 'roadmap-linear-mirror' && check.status === 'pass')); + assert.ok(parsed.checks.some(check => check.id === 'supply-chain-runbook' && check.status === 'pass')); + assert.deepStrictEqual(parsed.top_actions, []); + } finally { + cleanup(projectRoot); + } + })) passed++; else failed++; + + if (test('github queue and discussion budgets pass with maintainer touch', () => { + const projectRoot = createTempDir('platform-audit-github-pass-'); + + try { + seedRepo(projectRoot); + const shimPath = writeGhShim(projectRoot, { + 'pr list --repo affaan-m/everything-claude-code --state open --json number,title,isDraft,mergeStateStatus,updatedAt,url,author': [], + 'issue list --repo affaan-m/everything-claude-code --state open --json number,title,updatedAt,url,author,labels': [], + 'api graphql -f owner=affaan-m -f name=everything-claude-code -F first=100 -f query=query($owner: String!, $name: String!, $first: Int!) { repository(owner: $owner, name: $name) { hasDiscussionsEnabled discussions(first: $first, orderBy: {field: UPDATED_AT, direction: DESC}) { totalCount nodes { number title url updatedAt authorAssociation comments(first: 20) { nodes { authorAssociation } } } } } }': { + data: { + repository: { + hasDiscussionsEnabled: true, + discussions: { + totalCount: 1, + nodes: [ + { + number: 73, + title: 'Compacting during workflow', + url: 'https://github.com/example/discussions/73', + updatedAt: '2026-05-15T00:00:00Z', + authorAssociation: 'NONE', + comments: { nodes: [{ authorAssociation: 'OWNER' }] } + } + ] + } + } + } + } + }); + + const parsed = JSON.parse(run([ + '--format=json', + `--root=${projectRoot}`, + '--repo', + 'affaan-m/everything-claude-code' + ], { + cwd: projectRoot, + env: { + ECC_GH_SHIM: shimPath, + GITHUB_TOKEN: 'must-be-removed' + } + })); + + assert.strictEqual(parsed.ready, true); + assert.strictEqual(parsed.github.totals.openPrs, 0); + assert.strictEqual(parsed.github.totals.openIssues, 0); + assert.strictEqual(parsed.github.totals.discussionsNeedingMaintainerTouch, 0); + assert.ok(parsed.checks.some(check => check.id === 'github-discussion-touch' && check.status === 'pass')); + } finally { + cleanup(projectRoot); + } + })) passed++; else failed++; + + if (test('threshold failures and untouched discussions become top actions', () => { + const projectRoot = createTempDir('platform-audit-github-fail-'); + + try { + seedRepo(projectRoot); + const prs = Array.from({ length: 3 }, (_, index) => ({ + number: index + 1, + title: `PR ${index + 1}`, + isDraft: false, + mergeStateStatus: 'CLEAN', + updatedAt: '2026-05-15T00:00:00Z', + url: `https://github.com/example/pull/${index + 1}`, + author: { login: 'contributor' } + })); + const shimPath = writeGhShim(projectRoot, { + 'pr list --repo affaan-m/everything-claude-code --state open --json number,title,isDraft,mergeStateStatus,updatedAt,url,author': prs, + 'issue list --repo affaan-m/everything-claude-code --state open --json number,title,updatedAt,url,author,labels': [], + 'api graphql -f owner=affaan-m -f name=everything-claude-code -F first=100 -f query=query($owner: String!, $name: String!, $first: Int!) { repository(owner: $owner, name: $name) { hasDiscussionsEnabled discussions(first: $first, orderBy: {field: UPDATED_AT, direction: DESC}) { totalCount nodes { number title url updatedAt authorAssociation comments(first: 20) { nodes { authorAssociation } } } } } }': { + data: { + repository: { + hasDiscussionsEnabled: true, + discussions: { + totalCount: 1, + nodes: [ + { + number: 1239, + title: 'Losing context', + url: 'https://github.com/example/discussions/1239', + updatedAt: '2026-05-15T00:00:00Z', + authorAssociation: 'NONE', + comments: { nodes: [] } + } + ] + } + } + } + } + }); + + const parsed = JSON.parse(run([ + '--format=json', + `--root=${projectRoot}`, + '--repo', + 'affaan-m/everything-claude-code', + '--max-open-prs', + '2' + ], { + cwd: projectRoot, + env: { ECC_GH_SHIM: shimPath } + })); + + assert.strictEqual(parsed.ready, false); + assert.ok(parsed.top_actions.some(action => action.id === 'github-open-pr-budget')); + assert.ok(parsed.top_actions.some(action => action.id === 'github-discussion-touch')); + assert.strictEqual(parsed.github.totals.discussionsNeedingMaintainerTouch, 1); + } finally { + cleanup(projectRoot); + } + })) passed++; else failed++; + + if (test('cli help and invalid args exit cleanly', () => { + const help = runProcess(['--help']); + assert.strictEqual(help.status, 0); + assert.ok(help.stdout.includes('Usage: node scripts/platform-audit.js')); + + const invalid = runProcess(['--format', 'xml']); + assert.strictEqual(invalid.status, 1); + assert.ok(invalid.stderr.includes('Invalid format')); + })) passed++; else failed++; + + console.log(`\nPassed: ${passed}`); + console.log(`Failed: ${failed}`); + + if (failed > 0) { + process.exit(1); + } +} + +if (require.main === module) { + runTests(); +}