@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 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 */
@@ -4,4 +4,5 @@ export * from "./loader";
4
4
  export * from "./manager";
5
5
  export * from "./marketplace";
6
6
  export * from "./parser";
7
+ export * from "./security-scanner";
7
8
  export type * from "./types";
@@ -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.6",
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.6",
55
- "@sayknow-cli/agent-core": "0.2.6",
56
- "@sayknow-cli/ai": "0.2.6",
57
- "@sayknow-cli/natives": "0.2.6",
58
- "@sayknow-cli/tui": "0.2.6",
59
- "@sayknow-cli/utils": "0.2.6",
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",
@@ -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
- console.log(
372
- chalk.green(
373
- `${theme.status.success} Installed ${target.name} from ${target.marketplace} (${entry.version})`,
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
- console.log(JSON.stringify(result, null, 2));
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
  // ═══════════════════════════════════════════════════════════════════════════
@@ -6,4 +6,5 @@ export * from "./loader";
6
6
  export * from "./manager";
7
7
  export * from "./marketplace";
8
8
  export * from "./parser";
9
+ export * from "./security-scanner";
9
10
  export type * from "./types";
@@ -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
+ }