@sayknow-cli/coding-agent 0.2.6 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/dist/types/config/settings-schema.d.ts +33 -0
- package/dist/types/extensibility/plugins/index.d.ts +1 -0
- package/dist/types/extensibility/plugins/security-scanner.d.ts +31 -0
- package/package.json +7 -7
- package/src/cli/plugin-cli.ts +63 -6
- package/src/config/settings-schema.ts +32 -0
- package/src/extensibility/plugins/index.ts +1 -0
- package/src/extensibility/plugins/manager.ts +50 -0
- package/src/extensibility/plugins/marketplace/manager.ts +42 -0
- package/src/extensibility/plugins/security-scanner.ts +477 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,18 @@ Sayknow-CLI is a rebranded fork of [gajae-code](https://github.com/Yeachan-Heo/g
|
|
|
4
4
|
This file tracks the **fork's own releases**; upstream's full feature history lives
|
|
5
5
|
in that project. Each release notes the upstream version it is built on.
|
|
6
6
|
|
|
7
|
+
## [0.2.7] — 2026-06-23
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Plugin install security scan (advisory).** Newly installed plugins/skills are now
|
|
12
|
+
statically scanned before activation for risky patterns — `curl|bash` download-and-exec,
|
|
13
|
+
`eval`/dynamic import, credential/secret access, obfuscation, cron persistence, and
|
|
14
|
+
package-install markers — with risk scoring. Findings surface as warnings in the install
|
|
15
|
+
output and in `plugin doctor`. Controlled by `plugins.security.scanMode`
|
|
16
|
+
(`warn` = default, `off`, `block`) and `plugins.security.riskThreshold`. Warn-only by
|
|
17
|
+
default — it never blocks an install unless you opt into `block` mode.
|
|
18
|
+
|
|
7
19
|
## [0.2.6] — 2026-06-22
|
|
8
20
|
|
|
9
21
|
### Fixed
|
|
@@ -3433,6 +3433,39 @@ export declare const SETTINGS_SCHEMA: {
|
|
|
3433
3433
|
readonly type: "number";
|
|
3434
3434
|
readonly default: 65536;
|
|
3435
3435
|
};
|
|
3436
|
+
readonly "plugins.security.scanMode": {
|
|
3437
|
+
readonly type: "enum";
|
|
3438
|
+
readonly values: readonly ["warn", "off", "block"];
|
|
3439
|
+
readonly default: "warn";
|
|
3440
|
+
readonly ui: {
|
|
3441
|
+
readonly tab: "interaction";
|
|
3442
|
+
readonly label: "Plugin Security Scan Mode";
|
|
3443
|
+
readonly description: "warn: scan on install and log advisory findings (default); off: skip scan; block: deny install when score exceeds threshold";
|
|
3444
|
+
readonly options: readonly [{
|
|
3445
|
+
readonly value: "warn";
|
|
3446
|
+
readonly label: "Warn";
|
|
3447
|
+
readonly description: "Scan and log advisory findings (default)";
|
|
3448
|
+
}, {
|
|
3449
|
+
readonly value: "off";
|
|
3450
|
+
readonly label: "Off";
|
|
3451
|
+
readonly description: "Disable security scanning";
|
|
3452
|
+
}, {
|
|
3453
|
+
readonly value: "block";
|
|
3454
|
+
readonly label: "Block";
|
|
3455
|
+
readonly description: "Deny install when risk score exceeds threshold";
|
|
3456
|
+
}];
|
|
3457
|
+
};
|
|
3458
|
+
};
|
|
3459
|
+
readonly "plugins.security.riskThreshold": {
|
|
3460
|
+
readonly type: "number";
|
|
3461
|
+
readonly default: 60;
|
|
3462
|
+
readonly validate: (value: number) => boolean;
|
|
3463
|
+
readonly ui: {
|
|
3464
|
+
readonly tab: "interaction";
|
|
3465
|
+
readonly label: "Plugin Security Risk Threshold";
|
|
3466
|
+
readonly description: "Score threshold (0\u2013100) above which block mode denies install. Default 60.";
|
|
3467
|
+
};
|
|
3468
|
+
};
|
|
3436
3469
|
};
|
|
3437
3470
|
type Schema = typeof SETTINGS_SCHEMA;
|
|
3438
3471
|
/** All valid setting paths */
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { DoctorCheck } from "./types";
|
|
2
|
+
export type RiskLevel = "none" | "low" | "medium" | "high";
|
|
3
|
+
export type FindingCategory = "credential" | "execution" | "network" | "obfuscation" | "package_install" | "persistence";
|
|
4
|
+
export interface SecurityFinding {
|
|
5
|
+
category: FindingCategory;
|
|
6
|
+
id: string;
|
|
7
|
+
label: string;
|
|
8
|
+
severity: "low" | "medium" | "high";
|
|
9
|
+
file: string;
|
|
10
|
+
line?: number;
|
|
11
|
+
snippet?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface ScanReport {
|
|
14
|
+
findings: SecurityFinding[];
|
|
15
|
+
networkUrls: string[];
|
|
16
|
+
riskLevel: RiskLevel;
|
|
17
|
+
score: number;
|
|
18
|
+
reasoning: string;
|
|
19
|
+
recommendation: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Recursively scan `dir` for risky patterns.
|
|
23
|
+
* Never throws — on any top-level error returns an empty safe report.
|
|
24
|
+
*/
|
|
25
|
+
export declare function scanPluginDir(dir: string): Promise<ScanReport>;
|
|
26
|
+
/**
|
|
27
|
+
* Convert a ScanReport into DoctorCheck entries for the plugin health check.
|
|
28
|
+
* Returns [] when riskLevel is "none".
|
|
29
|
+
* NEVER emits status:"error" — advisory only.
|
|
30
|
+
*/
|
|
31
|
+
export declare function toDoctorChecks(pluginName: string, report: ScanReport): DoctorCheck[];
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@sayknow-cli/coding-agent",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.7",
|
|
5
5
|
"description": "Sayknow-CLI CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://github.com/jaybeyond/Sayknow_CLI",
|
|
7
7
|
"author": "jaybeyond",
|
|
@@ -51,12 +51,12 @@
|
|
|
51
51
|
"@agentclientprotocol/sdk": "0.21.0",
|
|
52
52
|
"@babel/parser": "^7.29.3",
|
|
53
53
|
"@mozilla/readability": "^0.6.0",
|
|
54
|
-
"@sayknow-cli/stats": "0.2.
|
|
55
|
-
"@sayknow-cli/agent-core": "0.2.
|
|
56
|
-
"@sayknow-cli/ai": "0.2.
|
|
57
|
-
"@sayknow-cli/natives": "0.2.
|
|
58
|
-
"@sayknow-cli/tui": "0.2.
|
|
59
|
-
"@sayknow-cli/utils": "0.2.
|
|
54
|
+
"@sayknow-cli/stats": "0.2.7",
|
|
55
|
+
"@sayknow-cli/agent-core": "0.2.7",
|
|
56
|
+
"@sayknow-cli/ai": "0.2.7",
|
|
57
|
+
"@sayknow-cli/natives": "0.2.7",
|
|
58
|
+
"@sayknow-cli/tui": "0.2.7",
|
|
59
|
+
"@sayknow-cli/utils": "0.2.7",
|
|
60
60
|
"@puppeteer/browsers": "^2.13.0",
|
|
61
61
|
"@types/turndown": "5.0.6",
|
|
62
62
|
"@xterm/headless": "^6.0.0",
|
package/src/cli/plugin-cli.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { APP_NAME, getProjectDir } from "@sayknow-cli/utils";
|
|
8
8
|
import chalk from "chalk";
|
|
9
|
+
import { Settings } from "../config/settings";
|
|
9
10
|
import { resolveOrDefaultProjectRegistryPath } from "../discovery/helpers";
|
|
10
11
|
import { PluginManager, parseSettingValue, validateSetting } from "../extensibility/plugins";
|
|
11
12
|
import {
|
|
@@ -15,6 +16,7 @@ import {
|
|
|
15
16
|
getPluginsCacheDir,
|
|
16
17
|
MarketplaceManager,
|
|
17
18
|
} from "../extensibility/plugins/marketplace/index.js";
|
|
19
|
+
import { type ScanReport, scanPluginDir } from "../extensibility/plugins/security-scanner";
|
|
18
20
|
import { theme } from "../modes/theme/theme";
|
|
19
21
|
|
|
20
22
|
// =============================================================================
|
|
@@ -147,6 +149,10 @@ export { classifyInstallTarget } from "./classify-install-target";
|
|
|
147
149
|
* Run a plugin command.
|
|
148
150
|
*/
|
|
149
151
|
export async function runPluginCommand(cmd: PluginCommandArgs): Promise<void> {
|
|
152
|
+
// Initialize settings so plugin commands can read config (e.g. the install-time
|
|
153
|
+
// security-scan mode/threshold); mirrors the other CLI command handlers. The scan
|
|
154
|
+
// itself also tolerates an uninitialized Settings, so this is best-effort.
|
|
155
|
+
await Settings.init().catch(() => {});
|
|
150
156
|
const manager = new PluginManager();
|
|
151
157
|
|
|
152
158
|
switch (cmd.action) {
|
|
@@ -342,6 +348,21 @@ async function handleUpgrade(args: string[], flags: PluginCommandArgs["flags"]):
|
|
|
342
348
|
}
|
|
343
349
|
}
|
|
344
350
|
|
|
351
|
+
function printSecurityAdvisory(report: ScanReport): void {
|
|
352
|
+
const riskColor = report.riskLevel === "high" ? chalk.red : report.riskLevel === "medium" ? chalk.yellow : chalk.dim;
|
|
353
|
+
console.log(
|
|
354
|
+
riskColor(`${theme.status.warning} Security advisory: ${report.riskLevel} risk (score ${report.score})`),
|
|
355
|
+
);
|
|
356
|
+
for (const f of report.findings.slice(0, 5)) {
|
|
357
|
+
const loc = f.line !== undefined ? `${f.file}:${f.line}` : f.file;
|
|
358
|
+
console.log(chalk.dim(` ${f.id}: ${loc}`));
|
|
359
|
+
}
|
|
360
|
+
if (report.findings.length > 5) {
|
|
361
|
+
console.log(chalk.dim(` ... and ${report.findings.length - 5} more finding(s)`));
|
|
362
|
+
}
|
|
363
|
+
console.log(chalk.dim(" Advisory only (regex-based, may have false positives)."));
|
|
364
|
+
}
|
|
365
|
+
|
|
345
366
|
async function handleInstall(
|
|
346
367
|
manager: PluginManager,
|
|
347
368
|
packages: string[],
|
|
@@ -368,11 +389,28 @@ async function handleInstall(
|
|
|
368
389
|
force: flags.force,
|
|
369
390
|
scope: flags.scope,
|
|
370
391
|
});
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
392
|
+
if (flags.json) {
|
|
393
|
+
// advisory is attached below via the scan path
|
|
394
|
+
} else {
|
|
395
|
+
console.log(
|
|
396
|
+
chalk.green(
|
|
397
|
+
`${theme.status.success} Installed ${target.name} from ${target.marketplace} (${entry.version})`,
|
|
398
|
+
),
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
// Post-install advisory scan (non-blocking)
|
|
402
|
+
try {
|
|
403
|
+
const report = await scanPluginDir(entry.installPath);
|
|
404
|
+
if (report.riskLevel !== "none") {
|
|
405
|
+
if (flags.json) {
|
|
406
|
+
console.log(JSON.stringify({ installed: target.name, securityAdvisory: report }, null, 2));
|
|
407
|
+
} else {
|
|
408
|
+
printSecurityAdvisory(report);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
} catch {
|
|
412
|
+
// advisory — never fail install
|
|
413
|
+
}
|
|
376
414
|
} catch (err) {
|
|
377
415
|
console.error(chalk.red(`${theme.status.error} Failed to install ${spec}: ${err}`));
|
|
378
416
|
process.exit(1);
|
|
@@ -394,7 +432,17 @@ async function handleInstall(
|
|
|
394
432
|
const result = await manager.install(spec, { force: flags.force, dryRun: flags.dryRun });
|
|
395
433
|
|
|
396
434
|
if (flags.json) {
|
|
397
|
-
|
|
435
|
+
// Post-install advisory scan for JSON output
|
|
436
|
+
let securityAdvisory: ScanReport | undefined;
|
|
437
|
+
if (!flags.dryRun && result.path) {
|
|
438
|
+
try {
|
|
439
|
+
const report = await scanPluginDir(result.path);
|
|
440
|
+
if (report.riskLevel !== "none") securityAdvisory = report;
|
|
441
|
+
} catch {
|
|
442
|
+
// advisory
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
console.log(JSON.stringify(securityAdvisory ? { ...result, securityAdvisory } : result, null, 2));
|
|
398
446
|
} else {
|
|
399
447
|
if (flags.dryRun) {
|
|
400
448
|
console.log(chalk.dim(`[dry-run] Would install ${spec}`));
|
|
@@ -406,6 +454,15 @@ async function handleInstall(
|
|
|
406
454
|
if (result.manifest.description) {
|
|
407
455
|
console.log(chalk.dim(` ${result.manifest.description}`));
|
|
408
456
|
}
|
|
457
|
+
// Post-install advisory scan
|
|
458
|
+
if (result.path) {
|
|
459
|
+
try {
|
|
460
|
+
const report = await scanPluginDir(result.path);
|
|
461
|
+
if (report.riskLevel !== "none") printSecurityAdvisory(report);
|
|
462
|
+
} catch {
|
|
463
|
+
// advisory
|
|
464
|
+
}
|
|
465
|
+
}
|
|
409
466
|
}
|
|
410
467
|
}
|
|
411
468
|
} catch (err) {
|
|
@@ -2970,6 +2970,38 @@ export const SETTINGS_SCHEMA = {
|
|
|
2970
2970
|
"thinkingBudgets.xhigh": { type: "number", default: 32768 },
|
|
2971
2971
|
|
|
2972
2972
|
"thinkingBudgets.max": { type: "number", default: 65536 },
|
|
2973
|
+
|
|
2974
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
2975
|
+
// Plugins — security scanner
|
|
2976
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
2977
|
+
|
|
2978
|
+
"plugins.security.scanMode": {
|
|
2979
|
+
type: "enum",
|
|
2980
|
+
values: ["warn", "off", "block"] as const,
|
|
2981
|
+
default: "warn",
|
|
2982
|
+
ui: {
|
|
2983
|
+
tab: "interaction",
|
|
2984
|
+
label: "Plugin Security Scan Mode",
|
|
2985
|
+
description:
|
|
2986
|
+
"warn: scan on install and log advisory findings (default); off: skip scan; block: deny install when score exceeds threshold",
|
|
2987
|
+
options: [
|
|
2988
|
+
{ value: "warn", label: "Warn", description: "Scan and log advisory findings (default)" },
|
|
2989
|
+
{ value: "off", label: "Off", description: "Disable security scanning" },
|
|
2990
|
+
{ value: "block", label: "Block", description: "Deny install when risk score exceeds threshold" },
|
|
2991
|
+
],
|
|
2992
|
+
},
|
|
2993
|
+
},
|
|
2994
|
+
|
|
2995
|
+
"plugins.security.riskThreshold": {
|
|
2996
|
+
type: "number",
|
|
2997
|
+
default: 60,
|
|
2998
|
+
validate: (value: number) => Number.isFinite(value) && value >= 0 && value <= 100,
|
|
2999
|
+
ui: {
|
|
3000
|
+
tab: "interaction",
|
|
3001
|
+
label: "Plugin Security Risk Threshold",
|
|
3002
|
+
description: "Score threshold (0–100) above which block mode denies install. Default 60.",
|
|
3003
|
+
},
|
|
3004
|
+
},
|
|
2973
3005
|
} as const;
|
|
2974
3006
|
|
|
2975
3007
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -10,7 +10,9 @@ import {
|
|
|
10
10
|
isEnoent,
|
|
11
11
|
logger,
|
|
12
12
|
} from "@sayknow-cli/utils";
|
|
13
|
+
import { settings } from "../../config/settings";
|
|
13
14
|
import { extractPackageName, parsePluginSpec } from "./parser";
|
|
15
|
+
import { scanPluginDir, toDoctorChecks } from "./security-scanner";
|
|
14
16
|
import type {
|
|
15
17
|
DoctorCheck,
|
|
16
18
|
DoctorOptions,
|
|
@@ -211,6 +213,9 @@ export class PluginManager {
|
|
|
211
213
|
}
|
|
212
214
|
// null = use defaults
|
|
213
215
|
|
|
216
|
+
// Security scan — advisory hook, runs after package is on disk but before config is saved.
|
|
217
|
+
await this.#runSecurityScan(pkg.name, path.join(getPluginsNodeModules(), actualName));
|
|
218
|
+
|
|
214
219
|
// Update runtime config
|
|
215
220
|
const config = await this.#ensureConfigLoaded();
|
|
216
221
|
config.plugins[pkg.name] = {
|
|
@@ -614,6 +619,14 @@ export class PluginManager {
|
|
|
614
619
|
}
|
|
615
620
|
}
|
|
616
621
|
}
|
|
622
|
+
|
|
623
|
+
// Security scan for installed plugin
|
|
624
|
+
try {
|
|
625
|
+
const secReport = await scanPluginDir(pluginPath);
|
|
626
|
+
checks.push(...toDoctorChecks(name, secReport));
|
|
627
|
+
} catch {
|
|
628
|
+
// advisory — never fail doctor
|
|
629
|
+
}
|
|
617
630
|
}
|
|
618
631
|
|
|
619
632
|
// Check for orphaned runtime config entries
|
|
@@ -632,6 +645,43 @@ export class PluginManager {
|
|
|
632
645
|
return checks;
|
|
633
646
|
}
|
|
634
647
|
|
|
648
|
+
async #runSecurityScan(pluginName: string, dir: string): Promise<void> {
|
|
649
|
+
// Resolve the scan mode defensively — this can run from CLI paths where Settings
|
|
650
|
+
// is not initialized, and an advisory scan must NEVER abort an install.
|
|
651
|
+
let mode = "warn";
|
|
652
|
+
try {
|
|
653
|
+
mode = settings.get("plugins.security.scanMode");
|
|
654
|
+
} catch {
|
|
655
|
+
mode = "warn";
|
|
656
|
+
}
|
|
657
|
+
if (mode === "off") return;
|
|
658
|
+
try {
|
|
659
|
+
const report = await scanPluginDir(dir);
|
|
660
|
+
if (report.riskLevel === "none") return;
|
|
661
|
+
logger.warn("Plugin security scan flagged risky patterns", {
|
|
662
|
+
plugin: pluginName,
|
|
663
|
+
riskLevel: report.riskLevel,
|
|
664
|
+
score: report.score,
|
|
665
|
+
findings: report.findings,
|
|
666
|
+
});
|
|
667
|
+
// score: 100 = safe, lower = riskier — block when it drops to/under the threshold.
|
|
668
|
+
let threshold = 60;
|
|
669
|
+
try {
|
|
670
|
+
threshold = settings.get("plugins.security.riskThreshold");
|
|
671
|
+
} catch {
|
|
672
|
+
threshold = 60;
|
|
673
|
+
}
|
|
674
|
+
if (mode === "block" && report.score <= threshold) {
|
|
675
|
+
throw new Error(
|
|
676
|
+
`Plugin "${pluginName}" blocked by security policy: ${report.riskLevel} risk (score ${report.score}). Set plugins.security.scanMode=warn to override.`,
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
} catch (err) {
|
|
680
|
+
if (mode === "block") throw err;
|
|
681
|
+
logger.debug("Security scan skipped due to error", { plugin: pluginName, error: err });
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
635
685
|
async #fixMissingPlugin(): Promise<boolean> {
|
|
636
686
|
try {
|
|
637
687
|
const proc = Bun.spawn(["bun", "install"], {
|
|
@@ -11,6 +11,8 @@ import * as os from "node:os";
|
|
|
11
11
|
import * as path from "node:path";
|
|
12
12
|
|
|
13
13
|
import { isEnoent, logger, pathIsWithin } from "@sayknow-cli/utils";
|
|
14
|
+
import { settings } from "../../../config/settings";
|
|
15
|
+
import { scanPluginDir } from "../security-scanner";
|
|
14
16
|
|
|
15
17
|
import { cachePlugin } from "./cache";
|
|
16
18
|
import { classifySource, fetchMarketplace, parseMarketplaceCatalog, promoteCloneToCache } from "./fetcher";
|
|
@@ -298,6 +300,9 @@ export class MarketplaceManager {
|
|
|
298
300
|
}
|
|
299
301
|
}
|
|
300
302
|
|
|
303
|
+
// Security scan — advisory hook, runs after plugin is on disk but before registry write.
|
|
304
|
+
await this.#runSecurityScan(pluginId, cachePath);
|
|
305
|
+
|
|
301
306
|
// Only now clean up old entries — new cache succeeded, so it is safe to remove old ones.
|
|
302
307
|
if (existing && existing.length > 0) {
|
|
303
308
|
// Remove from scope-appropriate registry first, then cross-check refs before disk deletion.
|
|
@@ -745,6 +750,43 @@ export class MarketplaceManager {
|
|
|
745
750
|
}
|
|
746
751
|
}
|
|
747
752
|
|
|
753
|
+
async #runSecurityScan(pluginName: string, dir: string): Promise<void> {
|
|
754
|
+
// Resolve the scan mode defensively — this can run from CLI paths where Settings
|
|
755
|
+
// is not initialized, and an advisory scan must NEVER abort an install.
|
|
756
|
+
let mode = "warn";
|
|
757
|
+
try {
|
|
758
|
+
mode = settings.get("plugins.security.scanMode");
|
|
759
|
+
} catch {
|
|
760
|
+
mode = "warn";
|
|
761
|
+
}
|
|
762
|
+
if (mode === "off") return;
|
|
763
|
+
try {
|
|
764
|
+
const report = await scanPluginDir(dir);
|
|
765
|
+
if (report.riskLevel === "none") return;
|
|
766
|
+
logger.warn("Plugin security scan flagged risky patterns", {
|
|
767
|
+
plugin: pluginName,
|
|
768
|
+
riskLevel: report.riskLevel,
|
|
769
|
+
score: report.score,
|
|
770
|
+
findings: report.findings,
|
|
771
|
+
});
|
|
772
|
+
// score: 100 = safe, lower = riskier — block when it drops to/under the threshold.
|
|
773
|
+
let threshold = 60;
|
|
774
|
+
try {
|
|
775
|
+
threshold = settings.get("plugins.security.riskThreshold");
|
|
776
|
+
} catch {
|
|
777
|
+
threshold = 60;
|
|
778
|
+
}
|
|
779
|
+
if (mode === "block" && report.score <= threshold) {
|
|
780
|
+
throw new Error(
|
|
781
|
+
`Plugin "${pluginName}" blocked by security policy: ${report.riskLevel} risk (score ${report.score}). Set plugins.security.scanMode=warn to override.`,
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
} catch (err) {
|
|
785
|
+
if (mode === "block") throw err;
|
|
786
|
+
logger.debug("Security scan skipped due to error", { plugin: pluginName, error: err });
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
748
790
|
/**
|
|
749
791
|
* Compute the marketplace root directory for source resolution.
|
|
750
792
|
*
|
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
// Regex-based, false-positive-prone — ADVISORY ONLY. Never hard-block by default.
|
|
2
|
+
|
|
3
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
4
|
+
import { basename, extname, join, relative } from "node:path";
|
|
5
|
+
|
|
6
|
+
import type { DoctorCheck } from "./types";
|
|
7
|
+
|
|
8
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
export type RiskLevel = "none" | "low" | "medium" | "high";
|
|
11
|
+
|
|
12
|
+
export type FindingCategory =
|
|
13
|
+
| "credential"
|
|
14
|
+
| "execution"
|
|
15
|
+
| "network"
|
|
16
|
+
| "obfuscation"
|
|
17
|
+
| "package_install"
|
|
18
|
+
| "persistence";
|
|
19
|
+
|
|
20
|
+
export interface SecurityFinding {
|
|
21
|
+
category: FindingCategory;
|
|
22
|
+
id: string;
|
|
23
|
+
label: string;
|
|
24
|
+
severity: "low" | "medium" | "high";
|
|
25
|
+
file: string;
|
|
26
|
+
line?: number;
|
|
27
|
+
snippet?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ScanReport {
|
|
31
|
+
findings: SecurityFinding[];
|
|
32
|
+
networkUrls: string[];
|
|
33
|
+
riskLevel: RiskLevel;
|
|
34
|
+
score: number;
|
|
35
|
+
reasoning: string;
|
|
36
|
+
recommendation: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ─── Rule definitions ─────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
interface ScanRule {
|
|
42
|
+
id: string;
|
|
43
|
+
label: string;
|
|
44
|
+
pattern: RegExp;
|
|
45
|
+
category: FindingCategory;
|
|
46
|
+
severity: "low" | "medium" | "high";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const CREDENTIAL_RULES: ScanRule[] = [
|
|
50
|
+
{
|
|
51
|
+
id: "credential_keyword",
|
|
52
|
+
label: "Hardcoded credential keyword (API key/token/secret)",
|
|
53
|
+
pattern: /\b(api[_-]?key|secret|token|private[_-]?key|access[_-]?key|auth[_-]?token|bearer)\b/i,
|
|
54
|
+
category: "credential",
|
|
55
|
+
severity: "medium",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: "credential_access",
|
|
59
|
+
label: "Environment variable access (process.env / os.environ)",
|
|
60
|
+
pattern: /\b(os\.environ|getenv|process\.env|dotenv|load_dotenv)\b/i,
|
|
61
|
+
category: "credential",
|
|
62
|
+
severity: "low",
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: "crypto_wallet",
|
|
66
|
+
label: "Cryptocurrency wallet reference (wallet/seed/mnemonic)",
|
|
67
|
+
pattern: /\b(wallet\.dat|keystore|mnemonic|seed phrase|private key|ledger|trezor|metamask)\b/i,
|
|
68
|
+
category: "credential",
|
|
69
|
+
severity: "high",
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: "ssh_access",
|
|
73
|
+
label: "SSH key / config access",
|
|
74
|
+
pattern: /(~\/.ssh|\\.ssh|id_rsa|id_ed25519|authorized_keys)/i,
|
|
75
|
+
category: "credential",
|
|
76
|
+
severity: "medium",
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: "cloud_credentials",
|
|
80
|
+
label: "Cloud credential file reference (AWS / GCloud / Azure)",
|
|
81
|
+
pattern: /(\.aws\/credentials|\.aws\/config|\.config\/gcloud|\.azure\/)/i,
|
|
82
|
+
category: "credential",
|
|
83
|
+
severity: "medium",
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: "browser_data",
|
|
87
|
+
label: "Browser sensitive data access (cookies/passwords)",
|
|
88
|
+
pattern: /(Login Data|Chrome\/User Data|Firefox\/Profiles|Brave\/User Data)/i,
|
|
89
|
+
category: "credential",
|
|
90
|
+
severity: "medium",
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
const EXEC_RULES: ScanRule[] = [
|
|
95
|
+
{
|
|
96
|
+
id: "download_exec",
|
|
97
|
+
label: "Download-then-execute pattern (curl|wget piped to shell)",
|
|
98
|
+
pattern: /(curl\s+.*\|\s*(bash|sh)|wget\s+.*\|\s*(bash|sh)|powershell\s+.*-c|Invoke-Expression)/i,
|
|
99
|
+
category: "execution",
|
|
100
|
+
severity: "high",
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
id: "shell_exec",
|
|
104
|
+
label: "Arbitrary system command execution (shell/subprocess)",
|
|
105
|
+
pattern: /\b(subprocess\.Popen|os\.system|popen|child_process\.exec|Runtime\.getRuntime\(\)\.exec)\b/i,
|
|
106
|
+
category: "execution",
|
|
107
|
+
severity: "medium",
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
id: "dynamic_exec",
|
|
111
|
+
label: "Dynamic code loading (eval / import)",
|
|
112
|
+
pattern: /\b(eval\(|__import__|importlib|dlopen)\b/i,
|
|
113
|
+
category: "execution",
|
|
114
|
+
severity: "medium",
|
|
115
|
+
},
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
const PERSISTENCE_RULES: ScanRule[] = [
|
|
119
|
+
{
|
|
120
|
+
id: "persistence",
|
|
121
|
+
label: "Background persistence / scheduled job (cron/systemd/launchd)",
|
|
122
|
+
pattern: /\b(cron|crontab|@reboot|systemd|launchd\.plist|schtasks)\b/i,
|
|
123
|
+
category: "persistence",
|
|
124
|
+
severity: "high",
|
|
125
|
+
},
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
const OBFUSCATION_RULES: ScanRule[] = [
|
|
129
|
+
{
|
|
130
|
+
id: "obfuscation_encoding",
|
|
131
|
+
label: "Obfuscation / encoding (base64 / hex / XOR)",
|
|
132
|
+
pattern: /\b(base64|atob|btoa|fromCharCode|rot13|xor|unescape|eval\(atob)\b/i,
|
|
133
|
+
category: "obfuscation",
|
|
134
|
+
severity: "medium",
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
id: "geo_evasion",
|
|
138
|
+
label: "Locale/timezone evasion pattern",
|
|
139
|
+
pattern: /(timezone|Intl\.DateTimeFormat|locale|LANG=)\b/i,
|
|
140
|
+
category: "obfuscation",
|
|
141
|
+
severity: "low",
|
|
142
|
+
},
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
const PACKAGE_INSTALL_RULES: ScanRule[] = [
|
|
146
|
+
{
|
|
147
|
+
id: "npm_install",
|
|
148
|
+
label: "npm install invocation",
|
|
149
|
+
pattern: /\b(npm\s+install|npm\s+i\s|npx\s)/i,
|
|
150
|
+
category: "package_install",
|
|
151
|
+
severity: "low",
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
id: "pip_install",
|
|
155
|
+
label: "pip install invocation",
|
|
156
|
+
pattern: /\b(pip\s+install|pip3\s+install|python\s+-m\s+pip)\b/i,
|
|
157
|
+
category: "package_install",
|
|
158
|
+
severity: "low",
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
id: "apt_install",
|
|
162
|
+
label: "apt-get install invocation",
|
|
163
|
+
pattern: /\b(apt-get\s+install|apt\s+install)\b/i,
|
|
164
|
+
category: "package_install",
|
|
165
|
+
severity: "low",
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
id: "brew_install",
|
|
169
|
+
label: "brew install invocation",
|
|
170
|
+
pattern: /\b(brew\s+install)\b/i,
|
|
171
|
+
category: "package_install",
|
|
172
|
+
severity: "low",
|
|
173
|
+
},
|
|
174
|
+
];
|
|
175
|
+
|
|
176
|
+
const ALL_RULES: ScanRule[] = [
|
|
177
|
+
...CREDENTIAL_RULES,
|
|
178
|
+
...EXEC_RULES,
|
|
179
|
+
...PERSISTENCE_RULES,
|
|
180
|
+
...OBFUSCATION_RULES,
|
|
181
|
+
...PACKAGE_INSTALL_RULES,
|
|
182
|
+
];
|
|
183
|
+
|
|
184
|
+
const URL_REGEX = /https?:\/\/[^\s)\]"'<>]+/gi;
|
|
185
|
+
|
|
186
|
+
const TEXT_EXTS = new Set([
|
|
187
|
+
".md",
|
|
188
|
+
".py",
|
|
189
|
+
".js",
|
|
190
|
+
".ts",
|
|
191
|
+
".tsx",
|
|
192
|
+
".sh",
|
|
193
|
+
".json",
|
|
194
|
+
".yaml",
|
|
195
|
+
".yml",
|
|
196
|
+
".toml",
|
|
197
|
+
".txt",
|
|
198
|
+
".env",
|
|
199
|
+
".ini",
|
|
200
|
+
".conf",
|
|
201
|
+
".rb",
|
|
202
|
+
".go",
|
|
203
|
+
".java",
|
|
204
|
+
".html",
|
|
205
|
+
".css",
|
|
206
|
+
".xml",
|
|
207
|
+
".jsx",
|
|
208
|
+
".mjs",
|
|
209
|
+
".cjs",
|
|
210
|
+
]);
|
|
211
|
+
|
|
212
|
+
const SKIP_DIRS = new Set([
|
|
213
|
+
".git",
|
|
214
|
+
"node_modules",
|
|
215
|
+
"dist",
|
|
216
|
+
"build",
|
|
217
|
+
"__pycache__",
|
|
218
|
+
".venv",
|
|
219
|
+
"venv",
|
|
220
|
+
".idea",
|
|
221
|
+
".vscode",
|
|
222
|
+
"coverage",
|
|
223
|
+
]);
|
|
224
|
+
|
|
225
|
+
const ENV_FILENAMES = new Set([
|
|
226
|
+
".env",
|
|
227
|
+
".env.local",
|
|
228
|
+
".env.production",
|
|
229
|
+
".env.development",
|
|
230
|
+
".env.test",
|
|
231
|
+
".env.example",
|
|
232
|
+
"config.json",
|
|
233
|
+
"secrets.json",
|
|
234
|
+
]);
|
|
235
|
+
|
|
236
|
+
const MAX_FILE_SIZE = 2_000_000; // 2 MB
|
|
237
|
+
|
|
238
|
+
const SNIPPET_MAX_LEN = 120;
|
|
239
|
+
|
|
240
|
+
// ─── Score deduction per unique category ─────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
function deductionForCategory(category: FindingCategory): number {
|
|
243
|
+
switch (category) {
|
|
244
|
+
case "execution":
|
|
245
|
+
return 25;
|
|
246
|
+
case "persistence":
|
|
247
|
+
return 20;
|
|
248
|
+
case "credential":
|
|
249
|
+
return 15;
|
|
250
|
+
case "obfuscation":
|
|
251
|
+
return 10;
|
|
252
|
+
case "network":
|
|
253
|
+
return 5;
|
|
254
|
+
case "package_install":
|
|
255
|
+
return 5;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ─── Risk thresholds ──────────────────────────────────────────────────────────
|
|
260
|
+
//
|
|
261
|
+
// Flags with inherently high risk elevate the level regardless of score.
|
|
262
|
+
// Otherwise: score ≤ 50 → high, ≤ 75 → medium, ≤ 90 → low, 91-100 → none.
|
|
263
|
+
|
|
264
|
+
const HIGH_FLAGS = new Set(["download_exec", "persistence", "crypto_wallet"]);
|
|
265
|
+
const MEDIUM_FLAGS = new Set([
|
|
266
|
+
"shell_exec",
|
|
267
|
+
"dynamic_exec",
|
|
268
|
+
"ssh_access",
|
|
269
|
+
"cloud_credentials",
|
|
270
|
+
"browser_data",
|
|
271
|
+
"obfuscation_encoding",
|
|
272
|
+
"geo_evasion",
|
|
273
|
+
]);
|
|
274
|
+
|
|
275
|
+
function determineRiskLevel(flagIds: Set<string>, score: number, networkCount: number): RiskLevel {
|
|
276
|
+
for (const f of HIGH_FLAGS) {
|
|
277
|
+
if (flagIds.has(f)) return "high";
|
|
278
|
+
}
|
|
279
|
+
for (const f of MEDIUM_FLAGS) {
|
|
280
|
+
if (flagIds.has(f)) return "medium";
|
|
281
|
+
}
|
|
282
|
+
if (networkCount > 0) return "medium";
|
|
283
|
+
if (flagIds.size === 0) return "none";
|
|
284
|
+
if (score <= 50) return "high";
|
|
285
|
+
if (score <= 75) return "medium";
|
|
286
|
+
if (score <= 90) return "low";
|
|
287
|
+
// Findings are present here (the flagIds.size === 0 case returned "none" above), so a
|
|
288
|
+
// single low-weight pattern (e.g. a package-install / supply-chain marker scoring 95)
|
|
289
|
+
// still surfaces as an advisory instead of being silently suppressed.
|
|
290
|
+
return "low";
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function buildReasoning(flagIds: Set<string>, networkCount: number): string {
|
|
294
|
+
const reasons: string[] = [];
|
|
295
|
+
if (flagIds.has("download_exec")) reasons.push("download-then-execute pattern detected");
|
|
296
|
+
if (flagIds.has("persistence")) reasons.push("background persistence / scheduled job");
|
|
297
|
+
if (flagIds.has("crypto_wallet")) reasons.push("cryptocurrency wallet / private key access");
|
|
298
|
+
if (flagIds.has("shell_exec")) reasons.push("arbitrary system command execution");
|
|
299
|
+
if (flagIds.has("dynamic_exec")) reasons.push("dynamic code execution (eval/import)");
|
|
300
|
+
if (flagIds.has("ssh_access") || flagIds.has("cloud_credentials"))
|
|
301
|
+
reasons.push("SSH key or cloud credential access");
|
|
302
|
+
if (flagIds.has("browser_data")) reasons.push("browser sensitive data access");
|
|
303
|
+
if (flagIds.has("obfuscation_encoding")) reasons.push("code obfuscation / encoding signs");
|
|
304
|
+
if (networkCount > 2) reasons.push(`multiple external connections (${networkCount})`);
|
|
305
|
+
else if (networkCount > 0) reasons.push("external network URLs present");
|
|
306
|
+
return reasons.length > 0 ? reasons.join("; ") : "";
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function buildRecommendation(riskLevel: RiskLevel): string {
|
|
310
|
+
switch (riskLevel) {
|
|
311
|
+
case "high":
|
|
312
|
+
return "HIGH RISK: Do not install in environments with real credentials. Test only in an isolated sandbox.";
|
|
313
|
+
case "medium":
|
|
314
|
+
return "SUSPICIOUS: Test in an isolated environment and verify network traffic before activating.";
|
|
315
|
+
case "low":
|
|
316
|
+
return "LOW RISK: No clearly malicious patterns detected. Follow least-privilege principles.";
|
|
317
|
+
case "none":
|
|
318
|
+
return "No security concerns detected.";
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ─── Walker ───────────────────────────────────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
async function walkDir(
|
|
325
|
+
dir: string,
|
|
326
|
+
rootDir: string,
|
|
327
|
+
callback: (relPath: string, content: string, absPath: string) => void,
|
|
328
|
+
): Promise<void> {
|
|
329
|
+
let entries: import("node:fs").Dirent[];
|
|
330
|
+
try {
|
|
331
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
332
|
+
} catch {
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
for (const entry of entries) {
|
|
337
|
+
const fullPath = join(dir, entry.name);
|
|
338
|
+
|
|
339
|
+
if (entry.isDirectory()) {
|
|
340
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
341
|
+
await walkDir(fullPath, rootDir, callback);
|
|
342
|
+
} else if (entry.isFile()) {
|
|
343
|
+
const ext = extname(entry.name).toLowerCase();
|
|
344
|
+
const isText = TEXT_EXTS.has(ext) || ENV_FILENAMES.has(entry.name);
|
|
345
|
+
if (!isText) continue;
|
|
346
|
+
|
|
347
|
+
try {
|
|
348
|
+
const info = await stat(fullPath);
|
|
349
|
+
if (info.size > MAX_FILE_SIZE) continue;
|
|
350
|
+
} catch {
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
try {
|
|
355
|
+
const content = await readFile(fullPath, "utf-8");
|
|
356
|
+
const relPath = relative(rootDir, fullPath);
|
|
357
|
+
callback(relPath, content, fullPath);
|
|
358
|
+
} catch {
|
|
359
|
+
// ignore unreadable files
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Recursively scan `dir` for risky patterns.
|
|
369
|
+
* Never throws — on any top-level error returns an empty safe report.
|
|
370
|
+
*/
|
|
371
|
+
export async function scanPluginDir(dir: string): Promise<ScanReport> {
|
|
372
|
+
try {
|
|
373
|
+
const findings: SecurityFinding[] = [];
|
|
374
|
+
const networkUrls: string[] = [];
|
|
375
|
+
const flagIds = new Set<string>();
|
|
376
|
+
const seenFlagFiles = new Map<string, Set<string>>(); // flagId -> set of relPaths already recorded
|
|
377
|
+
|
|
378
|
+
await walkDir(dir, dir, (relPath, content) => {
|
|
379
|
+
// Sensitive file detection
|
|
380
|
+
if (ENV_FILENAMES.has(basename(relPath))) {
|
|
381
|
+
findings.push({
|
|
382
|
+
category: "credential",
|
|
383
|
+
id: "sensitive_file",
|
|
384
|
+
label: `Sensitive config file present: ${relPath}`,
|
|
385
|
+
severity: "medium",
|
|
386
|
+
file: relPath,
|
|
387
|
+
});
|
|
388
|
+
flagIds.add("sensitive_file");
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Per-line rule matching
|
|
392
|
+
const lines = content.split("\n");
|
|
393
|
+
for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
|
|
394
|
+
const line = lines[lineIdx];
|
|
395
|
+
for (const rule of ALL_RULES) {
|
|
396
|
+
if (!rule.pattern.test(line)) continue;
|
|
397
|
+
// Reset lastIndex for global regexes
|
|
398
|
+
rule.pattern.lastIndex = 0;
|
|
399
|
+
|
|
400
|
+
const fileSet = seenFlagFiles.get(rule.id) ?? new Set<string>();
|
|
401
|
+
seenFlagFiles.set(rule.id, fileSet);
|
|
402
|
+
// One finding per (rule.id, file) pair to avoid noise
|
|
403
|
+
if (fileSet.has(relPath)) continue;
|
|
404
|
+
fileSet.add(relPath);
|
|
405
|
+
|
|
406
|
+
flagIds.add(rule.id);
|
|
407
|
+
const snippet = line.trim().slice(0, SNIPPET_MAX_LEN);
|
|
408
|
+
findings.push({
|
|
409
|
+
category: rule.category,
|
|
410
|
+
id: rule.id,
|
|
411
|
+
label: rule.label,
|
|
412
|
+
severity: rule.severity,
|
|
413
|
+
file: relPath,
|
|
414
|
+
line: lineIdx + 1,
|
|
415
|
+
snippet,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
// Reset all global regexes after each line
|
|
419
|
+
for (const rule of ALL_RULES) rule.pattern.lastIndex = 0;
|
|
420
|
+
|
|
421
|
+
// URL extraction (not per-rule deduplicated)
|
|
422
|
+
URL_REGEX.lastIndex = 0;
|
|
423
|
+
for (const urlMatch of line.matchAll(URL_REGEX)) {
|
|
424
|
+
if (!networkUrls.includes(urlMatch[0])) networkUrls.push(urlMatch[0]);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
// Calculate score
|
|
430
|
+
const seenCategories = new Set<FindingCategory>();
|
|
431
|
+
let deduction = 0;
|
|
432
|
+
for (const f of findings) {
|
|
433
|
+
if (!seenCategories.has(f.category)) {
|
|
434
|
+
seenCategories.add(f.category);
|
|
435
|
+
deduction += deductionForCategory(f.category);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
const score = Math.max(0, Math.min(100, 100 - deduction));
|
|
439
|
+
const riskLevel = determineRiskLevel(flagIds, score, networkUrls.length);
|
|
440
|
+
|
|
441
|
+
return {
|
|
442
|
+
findings,
|
|
443
|
+
networkUrls,
|
|
444
|
+
riskLevel,
|
|
445
|
+
score,
|
|
446
|
+
reasoning: buildReasoning(flagIds, networkUrls.length),
|
|
447
|
+
recommendation: buildRecommendation(riskLevel),
|
|
448
|
+
};
|
|
449
|
+
} catch {
|
|
450
|
+
return { findings: [], networkUrls: [], riskLevel: "none", score: 0, reasoning: "", recommendation: "" };
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Convert a ScanReport into DoctorCheck entries for the plugin health check.
|
|
456
|
+
* Returns [] when riskLevel is "none".
|
|
457
|
+
* NEVER emits status:"error" — advisory only.
|
|
458
|
+
*/
|
|
459
|
+
export function toDoctorChecks(pluginName: string, report: ScanReport): DoctorCheck[] {
|
|
460
|
+
if (report.riskLevel === "none") return [];
|
|
461
|
+
|
|
462
|
+
const topFindings = report.findings.slice(0, 5);
|
|
463
|
+
const topLines = topFindings.map(f => `${f.id}: ${f.file}${f.line !== undefined ? `:${f.line}` : ""}`).join(", ");
|
|
464
|
+
|
|
465
|
+
const message =
|
|
466
|
+
`risk=${report.riskLevel} score=${report.score}` +
|
|
467
|
+
(topLines ? ` | ${topLines}` : "") +
|
|
468
|
+
(report.findings.length > 5 ? ` (+${report.findings.length - 5} more)` : "");
|
|
469
|
+
|
|
470
|
+
return [
|
|
471
|
+
{
|
|
472
|
+
name: `plugin:${pluginName}:security`,
|
|
473
|
+
status: "warning",
|
|
474
|
+
message,
|
|
475
|
+
},
|
|
476
|
+
];
|
|
477
|
+
}
|