Merge 21b74f7bdd2de32b92ee293f8a3ee0b9c0d7b73e into 8a05c05596ca9ba0735dafd8e359885de4c2c969
This commit is contained in:
commit
2f93d069fe
@ -21,6 +21,7 @@ import {
|
||||
} from "./frontmatter.js";
|
||||
import { resolvePluginSkillDirs } from "./plugin-skills.js";
|
||||
import { serializeByKey } from "./serialize.js";
|
||||
import { scanSource } from "../../security/skill-scanner.js";
|
||||
import type {
|
||||
ParsedSkillFrontmatter,
|
||||
SkillEligibilityContext,
|
||||
@ -512,6 +513,12 @@ function loadSkillEntries(
|
||||
let frontmatter: ParsedSkillFrontmatter = {};
|
||||
try {
|
||||
const raw = fs.readFileSync(skill.filePath, "utf-8");
|
||||
// SECURITY: scan skill source for malicious patterns before loading
|
||||
const findings = scanSource(raw, skill.filePath);
|
||||
const critical = findings.filter((f) => f.severity === "critical");
|
||||
if (critical.length > 0) {
|
||||
skillsLogger.warn(`[security] Skill "${skill.name}" has ${critical.length} critical finding(s): ${critical.map((f) => f.ruleId).join(", ")}`);
|
||||
}
|
||||
frontmatter = parseFrontmatter(raw);
|
||||
} catch {
|
||||
// ignore malformed skills
|
||||
|
||||
61
src/commands/doctor-verification-debt.ts
Normal file
61
src/commands/doctor-verification-debt.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { DoctorCommandOptions } from "./doctor.js";
|
||||
import { note } from "../terminal/note.js";
|
||||
import { resolveDefaultAgentId } from "../config/agent-id.js";
|
||||
import { resolveAgentWorkspaceDir } from "../config/paths.js";
|
||||
import {
|
||||
loadVerificationDebt,
|
||||
calculateDebtScore,
|
||||
getDebtSummary,
|
||||
} from "../security/verification-debt.js";
|
||||
|
||||
export async function noteVerificationDebt(
|
||||
cfg: OpenClawConfig,
|
||||
options: DoctorCommandOptions,
|
||||
): Promise<void> {
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
|
||||
|
||||
try {
|
||||
const state = await loadVerificationDebt({ workspaceDir });
|
||||
const score = calculateDebtScore(state);
|
||||
const summary = getDebtSummary(state);
|
||||
|
||||
if (summary.unresolved === 0) {
|
||||
note("✓ No verification debt — all security checks up to date.", "Security");
|
||||
return;
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`Verification Debt Score: ${score}`);
|
||||
lines.push("");
|
||||
lines.push(`Unresolved: ${summary.unresolved} / ${summary.total}`);
|
||||
lines.push("");
|
||||
|
||||
if (summary.highRisk.length > 0) {
|
||||
lines.push("⚠️ High-risk items (score ≥7):");
|
||||
for (const item of summary.highRisk.slice(0, 5)) {
|
||||
const ageDays = Math.floor((Date.now() - item.skippedAt) / (24 * 60 * 60 * 1000));
|
||||
lines.push(` - [${item.category}] ${item.description} (risk: ${item.riskScore}, ${ageDays}d)`);
|
||||
}
|
||||
if (summary.highRisk.length > 5) {
|
||||
lines.push(` ... and ${summary.highRisk.length - 5} more`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
lines.push("By category:");
|
||||
for (const [cat, count] of Object.entries(summary.byCategory)) {
|
||||
if (count > 0) {
|
||||
lines.push(` - ${cat}: ${count}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push("Run 'openclaw security audit --deep' to address security debt.");
|
||||
|
||||
const severity = score >= 20 ? "error" : score >= 10 ? "warn" : "info";
|
||||
note(lines.join("\n"), "Security", severity);
|
||||
} catch {
|
||||
// Debt file doesn't exist yet — that's fine, first run will create it
|
||||
}
|
||||
}
|
||||
@ -60,6 +60,7 @@ import {
|
||||
import { maybeRepairUiProtocolFreshness } from "./doctor-ui.js";
|
||||
import { maybeOfferUpdateBeforeDoctor } from "./doctor-update.js";
|
||||
import { noteWorkspaceStatus } from "./doctor-workspace-status.js";
|
||||
import { noteVerificationDebt } from "./doctor-verification-debt.js";
|
||||
import { MEMORY_SYSTEM_PROMPT, shouldSuggestMemorySystem } from "./doctor-workspace.js";
|
||||
import { noteOpenAIOAuthTlsPrerequisites } from "./oauth-tls-preflight.js";
|
||||
import { applyWizardMetadata, printWizardHeader, randomToken } from "./onboard-helpers.js";
|
||||
@ -381,5 +382,7 @@ export async function doctorCommand(
|
||||
}
|
||||
}
|
||||
|
||||
await noteVerificationDebt(cfg, options);
|
||||
|
||||
outro("Doctor complete.");
|
||||
}
|
||||
|
||||
@ -21,6 +21,7 @@ import {
|
||||
} from "../auto-reply/heartbeat.js";
|
||||
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||
import { HEARTBEAT_TOKEN } from "../auto-reply/tokens.js";
|
||||
import { detectSuspiciousPatterns } from "../security/external-content.js";
|
||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||
import { getChannelPlugin } from "../channels/plugins/index.js";
|
||||
import type { ChannelHeartbeatDeps } from "../channels/plugins/types.js";
|
||||
@ -453,6 +454,13 @@ async function resolveHeartbeatPreflight(params: {
|
||||
const heartbeatFilePath = path.join(workspaceDir, DEFAULT_HEARTBEAT_FILENAME);
|
||||
try {
|
||||
const heartbeatFileContent = await fs.readFile(heartbeatFilePath, "utf-8");
|
||||
// SECURITY: scan HEARTBEAT.md for injection patterns before injecting into agent prompt
|
||||
const suspiciousPatterns = detectSuspiciousPatterns(heartbeatFileContent);
|
||||
if (suspiciousPatterns.length > 0) {
|
||||
log.warn(
|
||||
`[security] Suspicious patterns detected in HEARTBEAT.md (patterns=${suspiciousPatterns.length}): ${suspiciousPatterns.slice(0, 3).join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (isHeartbeatContentEffectivelyEmpty(heartbeatFileContent)) {
|
||||
return {
|
||||
...basePreflight,
|
||||
|
||||
165
src/security/verification-debt.ts
Normal file
165
src/security/verification-debt.ts
Normal file
@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Verification Debt Tracker
|
||||
*
|
||||
* "Every skipped verification is a loan against future trust."
|
||||
*
|
||||
* This module tracks verification events that were deferred or skipped,
|
||||
* creating an auditable ledger of technical debt in the security posture.
|
||||
*/
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export type VerificationDebtCategory =
|
||||
| "security_audit"
|
||||
| "skill_scan"
|
||||
| "api_health"
|
||||
| "memory_injection"
|
||||
| "cron_rejection"
|
||||
| "external_content";
|
||||
|
||||
export type VerificationDebtEntry = {
|
||||
id: string;
|
||||
category: VerificationDebtCategory;
|
||||
description: string;
|
||||
skippedAt: number;
|
||||
reason: string;
|
||||
riskScore: number;
|
||||
resolved?: boolean;
|
||||
resolvedAt?: number;
|
||||
resolvedBy?: string;
|
||||
};
|
||||
|
||||
export type VerificationDebtState = {
|
||||
version: 1;
|
||||
entries: VerificationDebtEntry[];
|
||||
lastPruned: number;
|
||||
};
|
||||
|
||||
const DEBT_FILE = "verification-debt.json";
|
||||
|
||||
export async function loadVerificationDebt(params: { workspaceDir: string }): Promise<VerificationDebtState> {
|
||||
const debtPath = path.join(params.workspaceDir, "state", DEBT_FILE);
|
||||
try {
|
||||
const raw = await fs.readFile(debtPath, "utf-8");
|
||||
return JSON.parse(raw) as VerificationDebtState;
|
||||
} catch {
|
||||
return { version: 1, entries: [], lastPruned: Date.now() };
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveVerificationDebt(params: {
|
||||
workspaceDir: string;
|
||||
state: VerificationDebtState;
|
||||
}): Promise<void> {
|
||||
const debtPath = path.join(params.workspaceDir, "state", DEBT_FILE);
|
||||
const stateDir = path.dirname(debtPath);
|
||||
await fs.mkdir(stateDir, { recursive: true });
|
||||
await fs.writeFile(debtPath, JSON.stringify(params.state, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
export async function addVerificationDebt(params: {
|
||||
workspaceDir: string;
|
||||
category: VerificationDebtCategory;
|
||||
description: string;
|
||||
reason: string;
|
||||
riskScore: number;
|
||||
}): Promise<VerificationDebtEntry> {
|
||||
const state = await loadVerificationDebt({ workspaceDir: params.workspaceDir });
|
||||
|
||||
const entry: VerificationDebtEntry = {
|
||||
id: crypto.randomUUID(),
|
||||
category: params.category,
|
||||
description: params.description,
|
||||
skippedAt: Date.now(),
|
||||
reason: params.reason,
|
||||
riskScore: Math.max(1, Math.min(10, params.riskScore)),
|
||||
};
|
||||
|
||||
state.entries.push(entry);
|
||||
await saveVerificationDebt({ workspaceDir: params.workspaceDir, state });
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
export async function resolveVerificationDebt(params: {
|
||||
workspaceDir: string;
|
||||
entryId: string;
|
||||
resolvedBy: string;
|
||||
}): Promise<boolean> {
|
||||
const state = await loadVerificationDebt({ workspaceDir: params.workspaceDir });
|
||||
|
||||
const entry = state.entries.find((e) => e.id === params.entryId);
|
||||
if (!entry || entry.resolved) {
|
||||
return false;
|
||||
}
|
||||
|
||||
entry.resolved = true;
|
||||
entry.resolvedAt = Date.now();
|
||||
entry.resolvedBy = params.resolvedBy;
|
||||
|
||||
await saveVerificationDebt({ workspaceDir: params.workspaceDir, state });
|
||||
return true;
|
||||
}
|
||||
|
||||
export function calculateDebtScore(state: VerificationDebtState): number {
|
||||
const now = Date.now();
|
||||
const oneWeekMs = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
return state.entries.reduce((score, entry) => {
|
||||
if (entry.resolved) return score;
|
||||
let entryScore = entry.riskScore;
|
||||
if (now - entry.skippedAt > oneWeekMs) {
|
||||
entryScore *= 2;
|
||||
}
|
||||
return score + entryScore;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export function getDebtSummary(state: VerificationDebtState): {
|
||||
total: number;
|
||||
unresolved: number;
|
||||
byCategory: Record<VerificationDebtCategory, number>;
|
||||
highRisk: VerificationDebtEntry[];
|
||||
} {
|
||||
const unresolved = state.entries.filter((e) => !e.resolved);
|
||||
const byCategory: Record<VerificationDebtCategory, number> = {
|
||||
security_audit: 0,
|
||||
skill_scan: 0,
|
||||
api_health: 0,
|
||||
memory_injection: 0,
|
||||
cron_rejection: 0,
|
||||
external_content: 0,
|
||||
};
|
||||
|
||||
for (const entry of unresolved) {
|
||||
byCategory[entry.category]++;
|
||||
}
|
||||
|
||||
const highRisk = unresolved.filter((e) => e.riskScore >= 7).sort((a, b) => b.riskScore - a.riskScore);
|
||||
|
||||
return {
|
||||
total: state.entries.length,
|
||||
unresolved: unresolved.length,
|
||||
byCategory,
|
||||
highRisk,
|
||||
};
|
||||
}
|
||||
|
||||
export async function pruneResolvedDebts(params: { workspaceDir: string; maxAgeDays: number }): Promise<number> {
|
||||
const state = await loadVerificationDebt({ workspaceDir: params.workspaceDir });
|
||||
const now = Date.now();
|
||||
const maxAgeMs = params.maxAgeDays * 24 * 60 * 60 * 1000;
|
||||
|
||||
const initialCount = state.entries.length;
|
||||
state.entries = state.entries.filter((entry) => {
|
||||
if (!entry.resolved) return true;
|
||||
if (now - (entry.resolvedAt ?? 0) < maxAgeMs) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
state.lastPruned = now;
|
||||
await saveVerificationDebt({ workspaceDir: params.workspaceDir, state });
|
||||
|
||||
return initialCount - state.entries.length;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user