praxis-sec 1.0.0

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.
Files changed (183) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +170 -0
  3. package/ai-defense/cost-protection.md +292 -0
  4. package/ai-defense/llm-security-checklist.md +324 -0
  5. package/ai-defense/prompt-injection-patterns.js +283 -0
  6. package/ai-defense/system-prompt-armor.md +327 -0
  7. package/checklists/launch-day.md +168 -0
  8. package/cli/agents/abom-generator.js +225 -0
  9. package/cli/agents/agent-attestation-agent.js +318 -0
  10. package/cli/agents/agent-config-scanner.js +787 -0
  11. package/cli/agents/agent-telemetry-agent.js +415 -0
  12. package/cli/agents/agentic-security-agent.js +296 -0
  13. package/cli/agents/agentic-supply-chain-agent.js +463 -0
  14. package/cli/agents/ai-infra-inventory-agent.js +449 -0
  15. package/cli/agents/api-fuzzer.js +345 -0
  16. package/cli/agents/auth-bypass-agent.js +348 -0
  17. package/cli/agents/base-agent.js +280 -0
  18. package/cli/agents/cicd-scanner.js +300 -0
  19. package/cli/agents/config-auditor.js +757 -0
  20. package/cli/agents/deep-analyzer.js +776 -0
  21. package/cli/agents/endpoint-agent-abuse-agent.js +404 -0
  22. package/cli/agents/exception-handler-agent.js +187 -0
  23. package/cli/agents/git-history-scanner.js +169 -0
  24. package/cli/agents/governance-audits.js +138 -0
  25. package/cli/agents/hermes-security-agent.js +536 -0
  26. package/cli/agents/html-reporter.js +1125 -0
  27. package/cli/agents/index.js +147 -0
  28. package/cli/agents/injection-tester.js +502 -0
  29. package/cli/agents/legal-risk-agent.js +328 -0
  30. package/cli/agents/llm-redteam.js +199 -0
  31. package/cli/agents/managed-agent-scanner.js +333 -0
  32. package/cli/agents/mcp-security-agent.js +588 -0
  33. package/cli/agents/memory-poisoning-agent.js +305 -0
  34. package/cli/agents/mobile-scanner.js +231 -0
  35. package/cli/agents/model-file-scanner.js +259 -0
  36. package/cli/agents/orchestrator.js +355 -0
  37. package/cli/agents/pii-compliance-agent.js +301 -0
  38. package/cli/agents/policy-engine.js +229 -0
  39. package/cli/agents/prompt-injection-prober.js +224 -0
  40. package/cli/agents/rag-security-agent.js +204 -0
  41. package/cli/agents/recon-agent.js +207 -0
  42. package/cli/agents/sbom-generator.js +265 -0
  43. package/cli/agents/scoring-engine.js +273 -0
  44. package/cli/agents/ssrf-prober.js +130 -0
  45. package/cli/agents/stateful-watcher.js +238 -0
  46. package/cli/agents/supabase-rls-agent.js +154 -0
  47. package/cli/agents/supply-chain-agent.js +857 -0
  48. package/cli/agents/swarm-orchestrator.js +200 -0
  49. package/cli/agents/verifier-agent.js +303 -0
  50. package/cli/agents/vibe-coding-agent.js +250 -0
  51. package/cli/bin/praxis.js +866 -0
  52. package/cli/commands/abom.js +73 -0
  53. package/cli/commands/agent-fix.js +1245 -0
  54. package/cli/commands/audit.js +1180 -0
  55. package/cli/commands/autofix.js +383 -0
  56. package/cli/commands/baseline.js +193 -0
  57. package/cli/commands/benchmark.js +327 -0
  58. package/cli/commands/checklist.js +223 -0
  59. package/cli/commands/ci.js +403 -0
  60. package/cli/commands/deps.js +516 -0
  61. package/cli/commands/diff.js +200 -0
  62. package/cli/commands/doctor.js +195 -0
  63. package/cli/commands/env-audit.js +349 -0
  64. package/cli/commands/fix.js +218 -0
  65. package/cli/commands/guard.js +396 -0
  66. package/cli/commands/hooks.js +278 -0
  67. package/cli/commands/init.js +514 -0
  68. package/cli/commands/legal.js +158 -0
  69. package/cli/commands/live-advisories.js +241 -0
  70. package/cli/commands/mcp.js +660 -0
  71. package/cli/commands/openclaw.js +386 -0
  72. package/cli/commands/red-team.js +350 -0
  73. package/cli/commands/redteam.js +78 -0
  74. package/cli/commands/remediate.js +797 -0
  75. package/cli/commands/rotate.js +768 -0
  76. package/cli/commands/rules.js +196 -0
  77. package/cli/commands/scan-mcp.js +534 -0
  78. package/cli/commands/scan-skill.js +588 -0
  79. package/cli/commands/scan-standard.js +251 -0
  80. package/cli/commands/scan.js +524 -0
  81. package/cli/commands/score.js +449 -0
  82. package/cli/commands/shell.js +514 -0
  83. package/cli/commands/team-report.js +398 -0
  84. package/cli/commands/undo.js +161 -0
  85. package/cli/commands/update-intel.js +126 -0
  86. package/cli/commands/vibe-check.js +276 -0
  87. package/cli/commands/watch.js +757 -0
  88. package/cli/commands/web.js +63 -0
  89. package/cli/core/ast/guardrail-detector.js +141 -0
  90. package/cli/core/ast/index.js +22 -0
  91. package/cli/core/ast/parser.js +676 -0
  92. package/cli/core/ast/scope-tree.js +287 -0
  93. package/cli/core/ast/taint-tracker.js +158 -0
  94. package/cli/core/branding.js +37 -0
  95. package/cli/core/env.js +38 -0
  96. package/cli/core/errors.js +61 -0
  97. package/cli/core/fs.js +62 -0
  98. package/cli/core/output/compliance.js +90 -0
  99. package/cli/core/output/html-theme.js +158 -0
  100. package/cli/core/output/index.js +57 -0
  101. package/cli/core/output/json.js +48 -0
  102. package/cli/core/output/sarif.js +240 -0
  103. package/cli/core/version.js +67 -0
  104. package/cli/core/web/jobs.js +183 -0
  105. package/cli/core/web/projects.js +146 -0
  106. package/cli/core/web/server.js +439 -0
  107. package/cli/data/atlas-knowledge.json +5640 -0
  108. package/cli/data/eaa-catalog.json +39 -0
  109. package/cli/data/known-mcps.json +26 -0
  110. package/cli/data/probes/prompt-injection-corpus.json +271 -0
  111. package/cli/data/threat-intel.json +85 -0
  112. package/cli/data/threatpacks/latest.json +41 -0
  113. package/cli/hooks/patterns.js +313 -0
  114. package/cli/hooks/post-tool-use.js +140 -0
  115. package/cli/hooks/pre-tool-use.js +186 -0
  116. package/cli/index.js +90 -0
  117. package/cli/providers/llm-provider.js +766 -0
  118. package/cli/utils/autofix-rules.js +74 -0
  119. package/cli/utils/cache-manager.js +310 -0
  120. package/cli/utils/compliance-map.js +191 -0
  121. package/cli/utils/entropy.js +132 -0
  122. package/cli/utils/fix-ledger.js +127 -0
  123. package/cli/utils/hermes-tool-registry.js +252 -0
  124. package/cli/utils/intel/cache.js +61 -0
  125. package/cli/utils/intel/http.js +88 -0
  126. package/cli/utils/intel/index.js +235 -0
  127. package/cli/utils/intel/merge.js +229 -0
  128. package/cli/utils/intel/sources/epss.js +54 -0
  129. package/cli/utils/intel/sources/ghsa.js +81 -0
  130. package/cli/utils/intel/sources/gitguardian.js +40 -0
  131. package/cli/utils/intel/sources/gitleaks.js +101 -0
  132. package/cli/utils/intel/sources/kev.js +38 -0
  133. package/cli/utils/intel/sources/nvd.js +84 -0
  134. package/cli/utils/intel/sources/osv.js +132 -0
  135. package/cli/utils/intel/sources/phylum.js +44 -0
  136. package/cli/utils/intel/sources/snyk.js +46 -0
  137. package/cli/utils/intel/sources/socket.js +69 -0
  138. package/cli/utils/intel/sources/sonatype.js +84 -0
  139. package/cli/utils/intel/sources/threatpack.js +69 -0
  140. package/cli/utils/mcp-trust.js +60 -0
  141. package/cli/utils/output.js +251 -0
  142. package/cli/utils/patterns.js +1130 -0
  143. package/cli/utils/pdf-generator.js +94 -0
  144. package/cli/utils/plugin-loader.js +364 -0
  145. package/cli/utils/rule-import.js +228 -0
  146. package/cli/utils/rule-registry.js +426 -0
  147. package/cli/utils/scan-fingerprint.js +109 -0
  148. package/cli/utils/scan-playbook.js +312 -0
  149. package/cli/utils/score-history.js +119 -0
  150. package/cli/utils/secrets-verifier.js +247 -0
  151. package/cli/utils/security-memory.js +296 -0
  152. package/cli/utils/standards/atlas-knowledge.js +87 -0
  153. package/cli/utils/standards/index.js +127 -0
  154. package/cli/utils/standards/sources/avid.js +45 -0
  155. package/cli/utils/standards/sources/eu-ai-act.js +89 -0
  156. package/cli/utils/standards/sources/google-saif.js +39 -0
  157. package/cli/utils/standards/sources/iso-42001.js +94 -0
  158. package/cli/utils/standards/sources/mitre-atlas.js +54 -0
  159. package/cli/utils/standards/sources/nist-ai-600-1.js +45 -0
  160. package/cli/utils/standards/sources/owasp-llm.js +45 -0
  161. package/cli/utils/standards/sources/owasp-ml.js +45 -0
  162. package/cli/utils/threat-intel.js +265 -0
  163. package/configs/firebase/firestore-rules.txt +215 -0
  164. package/configs/firebase/security-checklist.md +236 -0
  165. package/configs/firebase/storage-rules.txt +206 -0
  166. package/configs/gitignore-template +258 -0
  167. package/configs/nextjs-security-headers.js +220 -0
  168. package/configs/praxisignore-template +50 -0
  169. package/configs/supabase/secure-client.ts +225 -0
  170. package/configs/supabase/security-checklist.md +278 -0
  171. package/docs/THIRD_PARTY_NOTICES.md +26 -0
  172. package/docs/THREAT_INTEL.md +292 -0
  173. package/docs/USAGE.md +1205 -0
  174. package/docs/design/WEB-UI.md +82 -0
  175. package/package.json +71 -0
  176. package/scripts/check-determinism.mjs +119 -0
  177. package/snippets/README.md +122 -0
  178. package/snippets/api-security/api-security-checklist.md +412 -0
  179. package/snippets/api-security/cors-config.ts +322 -0
  180. package/snippets/api-security/input-validation.ts +430 -0
  181. package/snippets/auth/jwt-checklist.md +322 -0
  182. package/snippets/rate-limiting/nextjs-middleware.ts +211 -0
  183. package/snippets/rate-limiting/upstash-ratelimit.ts +229 -0
@@ -0,0 +1,94 @@
1
+ /**
2
+ * PDF Generator
3
+ * ==============
4
+ *
5
+ * Zero-dependency PDF generation via Chrome/Chromium headless mode.
6
+ * Falls back to generating a print-optimized HTML file if Chrome is not found.
7
+ */
8
+
9
+ import fs from 'fs';
10
+ import path from 'path';
11
+ import { execFileSync } from 'child_process';
12
+
13
+ /**
14
+ * Well-known Chrome/Chromium paths by platform.
15
+ */
16
+ function findChrome() {
17
+ const candidates = process.platform === 'win32'
18
+ ? [
19
+ process.env.CHROME_PATH,
20
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
21
+ 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
22
+ process.env.LOCALAPPDATA && path.join(process.env.LOCALAPPDATA, 'Google\\Chrome\\Application\\chrome.exe'),
23
+ ]
24
+ : process.platform === 'darwin'
25
+ ? [
26
+ process.env.CHROME_PATH,
27
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
28
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
29
+ ]
30
+ : [
31
+ process.env.CHROME_PATH,
32
+ '/usr/bin/google-chrome',
33
+ '/usr/bin/google-chrome-stable',
34
+ '/usr/bin/chromium',
35
+ '/usr/bin/chromium-browser',
36
+ '/snap/bin/chromium',
37
+ ];
38
+
39
+ for (const c of candidates) {
40
+ if (c && fs.existsSync(c)) return c;
41
+ }
42
+ return null;
43
+ }
44
+
45
+ /**
46
+ * Check if Chrome is available.
47
+ */
48
+ export function isChromeAvailable() {
49
+ return findChrome() !== null;
50
+ }
51
+
52
+ /**
53
+ * Generate PDF from an HTML file using Chrome headless.
54
+ * Returns the output path, or null if Chrome is not available.
55
+ */
56
+ export function generatePDF(htmlPath, outputPath) {
57
+ const chrome = findChrome();
58
+ if (!chrome) return null;
59
+
60
+ try {
61
+ const args = [
62
+ '--headless',
63
+ '--disable-gpu',
64
+ '--no-sandbox',
65
+ `--print-to-pdf=${outputPath}`,
66
+ '--print-to-pdf-no-header',
67
+ htmlPath,
68
+ ];
69
+ execFileSync(chrome, args, { timeout: 30000, stdio: 'pipe' }); // praxis-ignore — execFileSync with fixed chrome binary path; no user input in command
70
+ return outputPath;
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Generate a print-optimized HTML file as PDF fallback.
78
+ */
79
+ export function generatePrintHTML(htmlPath, outputPath) {
80
+ let html = fs.readFileSync(htmlPath, 'utf-8');
81
+ // Add print-optimized styles
82
+ const printCSS = `
83
+ <style media="print">
84
+ body { background: #fff !important; color: #1e293b !important; }
85
+ .score-card, .stat, .summary-card, .toc { background: #f8fafc !important; border: 1px solid #e2e8f0 !important; }
86
+ table, th, td { border: 1px solid #e2e8f0 !important; }
87
+ code { background: #f1f5f9 !important; color: #0f172a !important; }
88
+ pre { background: #f1f5f9 !important; }
89
+ a { color: #0369a1 !important; }
90
+ </style>`;
91
+ html = html.replace('</head>', printCSS + '\n</head>');
92
+ fs.writeFileSync(outputPath, html);
93
+ return outputPath;
94
+ }
@@ -0,0 +1,364 @@
1
+ /**
2
+ * Plugin Loader — Custom Agent Plugin System
3
+ * ============================================
4
+ *
5
+ * Allows users to drop custom security agents into `.praxis/agents/` and
6
+ * have them automatically loaded and run alongside the built-in agents.
7
+ *
8
+ * HOW IT WORKS:
9
+ * 1. On startup, loadPlugins(rootPath) scans `.praxis/agents/*.js`
10
+ * 2. Each file must export a default class that extends BaseAgent
11
+ * 3. Validated plugins are instantiated and returned for registration
12
+ * 4. buildOrchestrator() calls loadPlugins() and registers the results
13
+ *
14
+ * PLUGIN CONTRACT:
15
+ * A valid plugin must:
16
+ * - Export a default class (ES module)
17
+ * - Extend BaseAgent (from praxis's agent framework)
18
+ * - Implement `async analyze(context)` returning an array of findings
19
+ * - Set `this.name` and `this.category` in the constructor
20
+ *
21
+ * EXAMPLE PLUGIN:
22
+ *
23
+ * // .praxis/agents/my-rule.js
24
+ * import { BaseAgent, createFinding } from 'praxis';
25
+ *
26
+ * export default class MyCustomRule extends BaseAgent {
27
+ * constructor() {
28
+ * super();
29
+ * this.name = 'MyCustomRule';
30
+ * this.category = 'custom';
31
+ * }
32
+ *
33
+ * async analyze({ rootPath, files }) {
34
+ * const findings = [];
35
+ * for (const file of files) {
36
+ * const content = fs.readFileSync(file, 'utf-8');
37
+ * if (content.includes('eval(')) { // praxis-ignore — JSDoc example, not real eval
38
+ * findings.push(createFinding({
39
+ * rule: 'CUSTOM_EVAL',
40
+ * severity: 'high',
41
+ * title: 'Dangerous eval() usage', // praxis-ignore — JSDoc string literal
42
+ * description: 'eval() can execute arbitrary code', // praxis-ignore — JSDoc string literal
43
+ * file,
44
+ * remediation: 'Replace eval() with safer alternatives', // praxis-ignore — JSDoc string literal
45
+ * }));
46
+ * }
47
+ * }
48
+ * return findings;
49
+ * }
50
+ * }
51
+ *
52
+ * PLUGIN ISOLATION:
53
+ * Plugins run in the same process but each agent gets its own timeout (30s).
54
+ * A crashing or hanging plugin does not affect other agents.
55
+ *
56
+ * SECURITY NOTE:
57
+ * Plugins are arbitrary code executed from the local filesystem. Never install
58
+ * plugins from untrusted sources. praxis will warn if plugins are detected.
59
+ */
60
+
61
+ import fs from 'fs';
62
+ import path from 'path';
63
+ import { pathToFileURL } from 'url';
64
+ import { BaseAgent, createFinding } from '../agents/base-agent.js';
65
+
66
+ const PLUGIN_DIR = '.praxis/agents';
67
+
68
+ /**
69
+ * Load custom agent plugins from .praxis/agents/*.js
70
+ *
71
+ * @param {string} rootPath — project root directory
72
+ * @param {object} options — { verbose, quiet }
73
+ * @returns {Promise<object[]>} — array of instantiated agent objects
74
+ */
75
+ export async function loadPlugins(rootPath, options = {}) {
76
+ const pluginDir = path.join(rootPath, PLUGIN_DIR);
77
+
78
+ if (!fs.existsSync(pluginDir)) return [];
79
+
80
+ let files;
81
+ try {
82
+ files = fs.readdirSync(pluginDir)
83
+ .filter(f => f.endsWith('.js') || f.endsWith('.mjs'))
84
+ .map(f => path.join(pluginDir, f));
85
+ } catch {
86
+ return [];
87
+ }
88
+
89
+ if (files.length === 0) return [];
90
+
91
+ if (!options.quiet) {
92
+ console.log(` Loading ${files.length} plugin(s) from ${PLUGIN_DIR}...`);
93
+ }
94
+
95
+ // Expose the agent framework to plugins regardless of how praxis was
96
+ // launched (source tree or installed package) — plugins read this first.
97
+ globalThis.__praxisAgentFramework = { BaseAgent, createFinding };
98
+
99
+ const plugins = [];
100
+
101
+ for (const filePath of files) {
102
+ try {
103
+ const fileUrl = pathToFileURL(filePath).href;
104
+ const mod = await import(fileUrl);
105
+ const PluginClass = mod.default;
106
+
107
+ if (typeof PluginClass !== 'function') {
108
+ if (options.verbose) console.warn(` [plugin] ${path.basename(filePath)}: no default export class`);
109
+ continue;
110
+ }
111
+
112
+ // Validate the plugin before instantiation
113
+ const validation = validatePlugin(PluginClass, filePath);
114
+ if (!validation.valid) {
115
+ console.warn(` [plugin] ${path.basename(filePath)} skipped: ${validation.reason}`);
116
+ continue;
117
+ }
118
+
119
+ const instance = new PluginClass();
120
+
121
+ // Ensure required fields are set after construction
122
+ if (!instance.name) {
123
+ instance.name = path.basename(filePath, '.js');
124
+ }
125
+ if (!instance.category) {
126
+ instance.category = 'custom';
127
+ }
128
+
129
+ // Sandbox the analyze method to restrict file system access and block shell executions
130
+ const originalAnalyze = instance.analyze;
131
+ instance.analyze = async function(context) {
132
+ const child_process = await import('child_process');
133
+
134
+ const originalReadFileSync = fs.readFileSync;
135
+ const originalWriteFileSync = fs.writeFileSync;
136
+ const originalReadFile = fs.readFile;
137
+ const originalWriteFile = fs.writeFile;
138
+ const originalPromisesReadFile = fs.promises?.readFile;
139
+ const originalPromisesWriteFile = fs.promises?.writeFile;
140
+ const originalExec = child_process.exec;
141
+ const originalExecSync = child_process.execSync;
142
+ const originalSpawn = child_process.spawn;
143
+ const originalSpawnSync = child_process.spawnSync;
144
+
145
+ const isSafePath = (p) => {
146
+ if (!p) return false;
147
+ try {
148
+ const resolved = path.resolve(context.rootPath, String(p));
149
+ return resolved.startsWith(path.resolve(context.rootPath));
150
+ } catch {
151
+ return false;
152
+ }
153
+ };
154
+
155
+ fs.readFileSync = (p, ...args) => {
156
+ if (!isSafePath(p)) throw new Error(`Access denied (sandbox): read outside workspace path ${p}`);
157
+ return originalReadFileSync(p, ...args);
158
+ };
159
+ fs.writeFileSync = (p, ...args) => {
160
+ if (!isSafePath(p)) throw new Error(`Access denied (sandbox): write outside workspace path ${p}`);
161
+ return originalWriteFileSync(p, ...args);
162
+ };
163
+ fs.readFile = (p, ...args) => {
164
+ if (!isSafePath(p)) throw new Error(`Access denied (sandbox): read outside workspace path ${p}`);
165
+ return originalReadFile(p, ...args);
166
+ };
167
+ fs.writeFile = (p, ...args) => {
168
+ if (!isSafePath(p)) throw new Error(`Access denied (sandbox): write outside workspace path ${p}`);
169
+ return originalWriteFile(p, ...args);
170
+ };
171
+ if (fs.promises) {
172
+ fs.promises.readFile = async (p, ...args) => {
173
+ if (!isSafePath(p)) throw new Error(`Access denied (sandbox): read outside workspace path ${p}`);
174
+ return originalPromisesReadFile(p, ...args);
175
+ };
176
+ fs.promises.writeFile = async (p, ...args) => {
177
+ if (!isSafePath(p)) throw new Error(`Access denied (sandbox): write outside workspace path ${p}`);
178
+ return originalPromisesWriteFile(p, ...args);
179
+ };
180
+ }
181
+
182
+ // Best-effort lockdown: ESM module namespaces are immutable, so this
183
+ // only takes effect where child_process exposes mutable bindings.
184
+ // The fs path guard above still applies in all cases.
185
+ try {
186
+ child_process.exec = () => { throw new Error('Access denied (sandbox): exec not allowed in plugins'); };
187
+ child_process.execSync = () => { throw new Error('Access denied (sandbox): execSync not allowed in plugins'); };
188
+ child_process.spawn = () => { throw new Error('Access denied (sandbox): spawn not allowed in plugins'); };
189
+ child_process.spawnSync = () => { throw new Error('Access denied (sandbox): spawnSync not allowed in plugins'); };
190
+ } catch { /* immutable ESM namespace */ }
191
+
192
+ try {
193
+ return await originalAnalyze.call(this, context);
194
+ } finally {
195
+ fs.readFileSync = originalReadFileSync;
196
+ fs.writeFileSync = originalWriteFileSync;
197
+ fs.readFile = originalReadFile;
198
+ fs.writeFile = originalWriteFile;
199
+ if (fs.promises) {
200
+ fs.promises.readFile = originalPromisesReadFile;
201
+ fs.promises.writeFile = originalPromisesWriteFile;
202
+ }
203
+ try {
204
+ child_process.exec = originalExec;
205
+ child_process.execSync = originalExecSync;
206
+ child_process.spawn = originalSpawn;
207
+ child_process.spawnSync = originalSpawnSync;
208
+ } catch { /* immutable ESM namespace */ }
209
+ }
210
+ };
211
+
212
+ plugins.push(instance);
213
+
214
+ if (!options.quiet) {
215
+ console.log(` [plugin] Loaded: ${instance.name} (${instance.category})`);
216
+ }
217
+ } catch (err) {
218
+ console.warn(` [plugin] Failed to load ${path.basename(filePath)}: ${err.message}`);
219
+ }
220
+ }
221
+
222
+ return plugins;
223
+ }
224
+
225
+ /**
226
+ * Validate a plugin class before instantiation.
227
+ * Does static checks only — does not instantiate.
228
+ */
229
+ function validatePlugin(PluginClass, filePath) {
230
+ const name = path.basename(filePath);
231
+
232
+ if (typeof PluginClass !== 'function') {
233
+ return { valid: false, reason: 'default export is not a class/function' };
234
+ }
235
+
236
+ // Check prototype has analyze() — the required method
237
+ const proto = PluginClass.prototype;
238
+ if (typeof proto?.analyze !== 'function') {
239
+ return { valid: false, reason: 'class does not implement analyze()' };
240
+ }
241
+
242
+ return { valid: true };
243
+ }
244
+
245
+ /**
246
+ * List available plugins without loading them.
247
+ * Used by `praxis doctor` and `praxis plugins list`.
248
+ */
249
+ export function listPluginFiles(rootPath) {
250
+ const pluginDir = path.join(rootPath, PLUGIN_DIR);
251
+ if (!fs.existsSync(pluginDir)) return [];
252
+
253
+ try {
254
+ return fs.readdirSync(pluginDir)
255
+ .filter(f => f.endsWith('.js') || f.endsWith('.mjs'))
256
+ .map(f => ({
257
+ name: path.basename(f, '.js'),
258
+ path: path.join(pluginDir, f),
259
+ size: fs.statSync(path.join(pluginDir, f)).size,
260
+ }));
261
+ } catch {
262
+ return [];
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Scaffold a new plugin file in .praxis/agents/
268
+ */
269
+ export function scaffoldPlugin(rootPath, pluginName) {
270
+ const pluginDir = path.join(rootPath, PLUGIN_DIR);
271
+ if (!fs.existsSync(pluginDir)) {
272
+ fs.mkdirSync(pluginDir, { recursive: true });
273
+ }
274
+
275
+ const safeName = pluginName.replace(/[^a-zA-Z0-9_-]/g, '-');
276
+ const className = safeName.replace(/-([a-z])/g, (_, c) => c.toUpperCase()).replace(/^[a-z]/, c => c.toUpperCase());
277
+ const filePath = path.join(pluginDir, `${safeName}.js`);
278
+
279
+ if (fs.existsSync(filePath)) {
280
+ throw new Error(`Plugin already exists: ${filePath}`);
281
+ }
282
+
283
+ const template = `/**
284
+ * Custom Praxis Agent: ${className}
285
+ *
286
+ * Drop this file in .praxis/agents/ to have it run automatically
287
+ * as part of every \`praxis audit\` or \`praxis watch --deep\`.
288
+ *
289
+ * The \`analyze(context)\` method receives:
290
+ * context.rootPath — absolute path to the project root
291
+ * context.files — array of absolute file paths to scan
292
+ * context.recon — recon data (frameworks, databases, auth patterns)
293
+ * context.options — CLI options passed to the scan
294
+ *
295
+ * Return an array of findings using \`createFinding()\`.
296
+ */
297
+
298
+ import fs from 'fs';
299
+
300
+ // BaseAgent and createFinding are injected by the plugin loader at runtime.
301
+ // Fallbacks cover standalone use: the installed package, then source layout.
302
+ let BaseAgent, createFinding;
303
+ if (globalThis.__praxisAgentFramework) {
304
+ ({ BaseAgent, createFinding } = globalThis.__praxisAgentFramework);
305
+ } else {
306
+ try {
307
+ ({ BaseAgent, createFinding } = await import('praxis'));
308
+ } catch {
309
+ ({ BaseAgent, createFinding } = await import('praxis/cli/index.js'));
310
+ }
311
+ }
312
+
313
+ export default class ${className} extends BaseAgent {
314
+ constructor() {
315
+ super();
316
+ this.name = '${className}';
317
+ this.category = 'custom'; // or: secrets | injection | auth | config | api | llm
318
+ }
319
+
320
+ async analyze({ rootPath, files = [], recon, options }) {
321
+ const findings = [];
322
+
323
+ for (const file of files) {
324
+ // Skip files you don't care about
325
+ if (!/\\.(js|ts|jsx|tsx|py|rb|go|java)$/.test(file)) continue;
326
+
327
+ let content;
328
+ try {
329
+ content = fs.readFileSync(file, 'utf-8');
330
+ } catch {
331
+ continue;
332
+ }
333
+
334
+ const lines = content.split('\\n');
335
+ for (let i = 0; i < lines.length; i++) {
336
+ const line = lines[i];
337
+ if (/praxis-ignore/i.test(line)) continue; // respect suppression comments
338
+
339
+ // Example: flag dangerous eval calls // praxis-ignore
340
+ if (/\\beval\\s*\\(/.test(line)) { // praxis-ignore — template example in plugin scaffold, not real eval
341
+ findings.push(createFinding({
342
+ rule: '${safeName.toUpperCase().replace(/-/g, '_')}',
343
+ severity: 'high', // critical | high | medium | low
344
+ title: 'Example finding from ${className}',
345
+ description: 'Describe the security risk here.',
346
+ file,
347
+ line: i + 1,
348
+ matched: line.trim().slice(0, 100),
349
+ category: this.category,
350
+ remediation: 'Describe the fix here.',
351
+ confidence: 'medium', // high | medium | low
352
+ }));
353
+ }
354
+ }
355
+ }
356
+
357
+ return findings;
358
+ }
359
+ }
360
+ `;
361
+
362
+ fs.writeFileSync(filePath, template, 'utf-8');
363
+ return filePath;
364
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Portable rule import — the inverse of the export (P-IMP-060).
3
+ * ============================================================================
4
+ *
5
+ * Deliberately scoped, and the scope matters:
6
+ *
7
+ * ACCEPTS pattern rules — the same records the export produces, which map 1:1 onto
8
+ * what Praxis can execute.
9
+ * REJECTS anything else, with a stated reason, rather than importing it in a
10
+ * degraded form. Notably: AST/taint semantics, probe-corpus rules and
11
+ * entropy heuristics cannot be expressed as a static pattern, so a bundle
12
+ * claiming to carry them is told so rather than quietly losing them.
13
+ *
14
+ * This is NOT a general Semgrep parser. Praxis has no YAML runtime dependency (five
15
+ * runtime deps, none a YAML parser), so the canonical round-trip format is the JSON
16
+ * written alongside the Semgrep YAML. Handing it Semgrep YAML gets a clear error
17
+ * explaining the supported path instead of a silent failure.
18
+ *
19
+ * What it produces is a real, runnable Praxis plugin, so an imported rule participates
20
+ * in real scans rather than sitting inert on disk.
21
+ */
22
+
23
+ import fs from 'fs';
24
+ import path from 'path';
25
+
26
+ /**
27
+ * Loads a portable bundle.
28
+ *
29
+ * @param {string} file Path to `praxis-rules.json` (canonical) — Semgrep YAML is
30
+ * rejected with an explanation.
31
+ * @returns {{ok: true, accepted: object[], rejected: object[], meta: object}
32
+ * | {ok: false, error: string}}
33
+ */
34
+ export function loadPortableBundle(file) {
35
+ if (!file || typeof file !== 'string') {
36
+ return { ok: false, error: 'a bundle path is required' };
37
+ }
38
+ const ext = path.extname(file).toLowerCase();
39
+ if (ext === '.yaml' || ext === '.yml') {
40
+ return {
41
+ ok: false,
42
+ error:
43
+ 'Semgrep YAML is an export format, not an import format. Praxis has no YAML ' +
44
+ 'runtime dependency; import praxis-rules.json (written alongside the YAML) instead.',
45
+ };
46
+ }
47
+ if (ext !== '.json') {
48
+ return { ok: false, error: `unsupported bundle extension "${ext}" (expected .json)` };
49
+ }
50
+
51
+ let data;
52
+ try {
53
+ data = JSON.parse(fs.readFileSync(file, 'utf8'));
54
+ } catch (err) {
55
+ return { ok: false, error: `could not read bundle: ${err.message}` };
56
+ }
57
+
58
+ if (!data || !Array.isArray(data.rules)) {
59
+ return { ok: false, error: 'bundle has no `rules` array' };
60
+ }
61
+
62
+ const accepted = [];
63
+ const rejected = [];
64
+
65
+ for (const r of data.rules) {
66
+ if (!r || typeof r !== 'object') {
67
+ rejected.push({ id: r?.id ?? null, reason: 'not an object' });
68
+ continue;
69
+ }
70
+ if (typeof r.id !== 'string' || !r.id) {
71
+ rejected.push({ id: null, reason: 'missing id' });
72
+ continue;
73
+ }
74
+ if (typeof r.pattern !== 'string' || !r.pattern) {
75
+ rejected.push({
76
+ id: r.id,
77
+ reason: 'no portable pattern — AST/taint, probe-corpus and entropy rules cannot be expressed as a static pattern',
78
+ });
79
+ continue;
80
+ }
81
+
82
+ // Praxis executes JavaScript regexes, so convert the PCRE2 form back before use.
83
+ const { pattern: jsPattern, flags } = toJsPattern(r.pattern);
84
+ try {
85
+ // eslint-disable-next-line no-new
86
+ new RegExp(jsPattern, flags);
87
+ } catch (err) {
88
+ rejected.push({ id: r.id, reason: `pattern does not compile in JavaScript: ${err.message}` });
89
+ continue;
90
+ }
91
+
92
+ accepted.push({
93
+ id: r.id,
94
+ title: r.title || r.id,
95
+ severity: String(r.severity || 'medium').toLowerCase(),
96
+ description: r.description || r.title || r.id,
97
+ fix: r.fix || null,
98
+ cwe: r.cwe || null,
99
+ owasp: r.owasp || null,
100
+ jsPattern,
101
+ jsFlags: flags,
102
+ origin: r.origin || 'imported',
103
+ });
104
+ }
105
+
106
+ return {
107
+ ok: true,
108
+ accepted,
109
+ rejected,
110
+ meta: {
111
+ source: file,
112
+ total: data.rules.length,
113
+ generator: data.generator || null,
114
+ praxisVersion: data.praxisVersion || null,
115
+ praxisOnlyLayers: data.praxisOnlyLayers || [],
116
+ },
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Converts a PCRE2 portable pattern back into JavaScript form.
122
+ *
123
+ * Returns `{ pattern, flags }`. The inline flag groups are NOT simply stripped: doing so
124
+ * would silently turn every case-insensitive rule into a case-sensitive one. They are
125
+ * translated into JavaScript's flag position (`i`, `m`, `s`), which JavaScript supports.
126
+ * `x` (extended/free-spacing) has no JavaScript equivalent and is reported.
127
+ */
128
+ export function toJsPattern(portablePattern) {
129
+ let flags = '';
130
+ let s = String(portablePattern);
131
+
132
+ s = s.replace(/^(?:\(\?([imsx]+)\))+/, (_m, group) => {
133
+ for (const f of group) {
134
+ if ('ims'.includes(f) && !flags.includes(f)) flags += f;
135
+ }
136
+ return '';
137
+ });
138
+
139
+ // PCRE2 \x{...} -> JS \u{...}
140
+ s = s.replace(/\\x\{([0-9a-fA-F]+)\}/g, '\\u{$1}');
141
+
142
+ // Semgrep matches every occurrence; JavaScript needs the global flag to iterate.
143
+ if (!flags.includes('g')) flags += 'g';
144
+
145
+ return { pattern: s, flags };
146
+ }
147
+
148
+ /**
149
+ * Emits a runnable Praxis plugin for the accepted rules.
150
+ *
151
+ * Uses the same plugin contract as `plugin-loader` (`.praxis/agents/*.js`), so
152
+ * imported rules participate in real scans instead of sitting inert.
153
+ */
154
+ export function renderPlugin(accepted, { name = 'PortableRules' } = {}) {
155
+ const className = `${name.replace(/[^A-Za-z0-9]/g, '')}Agent`;
156
+ // Resolve at generation time so the emitted plugin has no unresolved placeholders.
157
+ // Mirrors plugin-loader: prefer the installed package, fall back to the source tree.
158
+ const packageRoot = (() => {
159
+ try {
160
+ return JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).name || 'praxis';
161
+ } catch {
162
+ return 'praxis';
163
+ }
164
+ })();
165
+ // `scanFileWithPatterns` requires a real RegExp object (it advances `lastIndex`),
166
+ // so the rules are emitted as constructed expressions rather than strings. Emitting
167
+ // the pattern as a bare string yields "Cannot create property 'lastIndex' on string".
168
+ // Built as JS source rather than JSON.stringify'd, because the regex must be code.
169
+ const q = (v) => JSON.stringify(v === undefined || v === null ? '' : String(v));
170
+ const rulesSource = accepted.map(r =>
171
+ ` { rule: ${q(r.id)}, title: ${q(r.title)}, regex: new RegExp(${q(r.jsPattern)}, ${q(r.jsFlags)}),` +
172
+ ` severity: ${q(r.severity)}, description: ${q(r.description)}, fix: ${q(r.fix)},` +
173
+ ` cwe: ${q(r.cwe)}, owasp: ${q(r.owasp)} }`
174
+ ).join(',\n');
175
+
176
+ return `/**
177
+ * Portable rules imported by \`praxis rules import\`.
178
+ *
179
+ * ${accepted.length} pattern rule(s) loaded from a Praxis portable bundle.
180
+ * Dropped in .praxis/agents/, this participates in every \`praxis audit\`.
181
+ *
182
+ * Regenerate with: praxis rules import <bundle.json> --write-plugin .praxis/agents
183
+ *
184
+ * Scope: pattern rules only. AST/taint analysis, the prompt-injection probe corpus
185
+ * and entropy heuristics are NOT importable — they are not static patterns.
186
+ */
187
+
188
+ // BaseAgent and createFinding are injected by the plugin loader at runtime.
189
+ let BaseAgent, createFinding;
190
+ if (globalThis.__praxisAgentFramework) {
191
+ ({ BaseAgent, createFinding } = globalThis.__praxisAgentFramework);
192
+ } else {
193
+ try {
194
+ ({ BaseAgent, createFinding } = await import('${packageRoot}')); // praxis-ignore AGENT_ESCALATED_PERMISSIONS — generated plugin importing the Praxis framework; same lines as plugin-loader.js
195
+ } catch {
196
+ ({ BaseAgent, createFinding } = await import('${packageRoot}/cli/index.js')); // praxis-ignore AGENT_ESCALATED_PERMISSIONS — generated plugin importing the Praxis framework; same lines as plugin-loader.js
197
+ }
198
+ }
199
+
200
+ const RULES = [
201
+ ${rulesSource}
202
+ ];
203
+
204
+ export default class ${className} extends BaseAgent {
205
+ constructor() {
206
+ super('${className}', 'Portable rules imported from a Praxis rule bundle', 'custom');
207
+ this.category = 'custom';
208
+ }
209
+
210
+ async analyze({ files = [] }) {
211
+ const findings = [];
212
+ for (const file of files) {
213
+ const results = this.scanFileWithPatterns(file, RULES);
214
+ findings.push(...results);
215
+ }
216
+ return findings;
217
+ }
218
+ }
219
+ `;
220
+ }
221
+
222
+ /** Writes the plugin into a directory, creating it if needed. */
223
+ export function writePlugin(accepted, dir, opts = {}) {
224
+ fs.mkdirSync(dir, { recursive: true });
225
+ const file = path.join(dir, 'portable-rules.js');
226
+ fs.writeFileSync(file, renderPlugin(accepted, opts), 'utf8');
227
+ return file;
228
+ }