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.
- package/LICENSE +21 -0
- package/README.md +170 -0
- package/ai-defense/cost-protection.md +292 -0
- package/ai-defense/llm-security-checklist.md +324 -0
- package/ai-defense/prompt-injection-patterns.js +283 -0
- package/ai-defense/system-prompt-armor.md +327 -0
- package/checklists/launch-day.md +168 -0
- package/cli/agents/abom-generator.js +225 -0
- package/cli/agents/agent-attestation-agent.js +318 -0
- package/cli/agents/agent-config-scanner.js +787 -0
- package/cli/agents/agent-telemetry-agent.js +415 -0
- package/cli/agents/agentic-security-agent.js +296 -0
- package/cli/agents/agentic-supply-chain-agent.js +463 -0
- package/cli/agents/ai-infra-inventory-agent.js +449 -0
- package/cli/agents/api-fuzzer.js +345 -0
- package/cli/agents/auth-bypass-agent.js +348 -0
- package/cli/agents/base-agent.js +280 -0
- package/cli/agents/cicd-scanner.js +300 -0
- package/cli/agents/config-auditor.js +757 -0
- package/cli/agents/deep-analyzer.js +776 -0
- package/cli/agents/endpoint-agent-abuse-agent.js +404 -0
- package/cli/agents/exception-handler-agent.js +187 -0
- package/cli/agents/git-history-scanner.js +169 -0
- package/cli/agents/governance-audits.js +138 -0
- package/cli/agents/hermes-security-agent.js +536 -0
- package/cli/agents/html-reporter.js +1125 -0
- package/cli/agents/index.js +147 -0
- package/cli/agents/injection-tester.js +502 -0
- package/cli/agents/legal-risk-agent.js +328 -0
- package/cli/agents/llm-redteam.js +199 -0
- package/cli/agents/managed-agent-scanner.js +333 -0
- package/cli/agents/mcp-security-agent.js +588 -0
- package/cli/agents/memory-poisoning-agent.js +305 -0
- package/cli/agents/mobile-scanner.js +231 -0
- package/cli/agents/model-file-scanner.js +259 -0
- package/cli/agents/orchestrator.js +355 -0
- package/cli/agents/pii-compliance-agent.js +301 -0
- package/cli/agents/policy-engine.js +229 -0
- package/cli/agents/prompt-injection-prober.js +224 -0
- package/cli/agents/rag-security-agent.js +204 -0
- package/cli/agents/recon-agent.js +207 -0
- package/cli/agents/sbom-generator.js +265 -0
- package/cli/agents/scoring-engine.js +273 -0
- package/cli/agents/ssrf-prober.js +130 -0
- package/cli/agents/stateful-watcher.js +238 -0
- package/cli/agents/supabase-rls-agent.js +154 -0
- package/cli/agents/supply-chain-agent.js +857 -0
- package/cli/agents/swarm-orchestrator.js +200 -0
- package/cli/agents/verifier-agent.js +303 -0
- package/cli/agents/vibe-coding-agent.js +250 -0
- package/cli/bin/praxis.js +866 -0
- package/cli/commands/abom.js +73 -0
- package/cli/commands/agent-fix.js +1245 -0
- package/cli/commands/audit.js +1180 -0
- package/cli/commands/autofix.js +383 -0
- package/cli/commands/baseline.js +193 -0
- package/cli/commands/benchmark.js +327 -0
- package/cli/commands/checklist.js +223 -0
- package/cli/commands/ci.js +403 -0
- package/cli/commands/deps.js +516 -0
- package/cli/commands/diff.js +200 -0
- package/cli/commands/doctor.js +195 -0
- package/cli/commands/env-audit.js +349 -0
- package/cli/commands/fix.js +218 -0
- package/cli/commands/guard.js +396 -0
- package/cli/commands/hooks.js +278 -0
- package/cli/commands/init.js +514 -0
- package/cli/commands/legal.js +158 -0
- package/cli/commands/live-advisories.js +241 -0
- package/cli/commands/mcp.js +660 -0
- package/cli/commands/openclaw.js +386 -0
- package/cli/commands/red-team.js +350 -0
- package/cli/commands/redteam.js +78 -0
- package/cli/commands/remediate.js +797 -0
- package/cli/commands/rotate.js +768 -0
- package/cli/commands/rules.js +196 -0
- package/cli/commands/scan-mcp.js +534 -0
- package/cli/commands/scan-skill.js +588 -0
- package/cli/commands/scan-standard.js +251 -0
- package/cli/commands/scan.js +524 -0
- package/cli/commands/score.js +449 -0
- package/cli/commands/shell.js +514 -0
- package/cli/commands/team-report.js +398 -0
- package/cli/commands/undo.js +161 -0
- package/cli/commands/update-intel.js +126 -0
- package/cli/commands/vibe-check.js +276 -0
- package/cli/commands/watch.js +757 -0
- package/cli/commands/web.js +63 -0
- package/cli/core/ast/guardrail-detector.js +141 -0
- package/cli/core/ast/index.js +22 -0
- package/cli/core/ast/parser.js +676 -0
- package/cli/core/ast/scope-tree.js +287 -0
- package/cli/core/ast/taint-tracker.js +158 -0
- package/cli/core/branding.js +37 -0
- package/cli/core/env.js +38 -0
- package/cli/core/errors.js +61 -0
- package/cli/core/fs.js +62 -0
- package/cli/core/output/compliance.js +90 -0
- package/cli/core/output/html-theme.js +158 -0
- package/cli/core/output/index.js +57 -0
- package/cli/core/output/json.js +48 -0
- package/cli/core/output/sarif.js +240 -0
- package/cli/core/version.js +67 -0
- package/cli/core/web/jobs.js +183 -0
- package/cli/core/web/projects.js +146 -0
- package/cli/core/web/server.js +439 -0
- package/cli/data/atlas-knowledge.json +5640 -0
- package/cli/data/eaa-catalog.json +39 -0
- package/cli/data/known-mcps.json +26 -0
- package/cli/data/probes/prompt-injection-corpus.json +271 -0
- package/cli/data/threat-intel.json +85 -0
- package/cli/data/threatpacks/latest.json +41 -0
- package/cli/hooks/patterns.js +313 -0
- package/cli/hooks/post-tool-use.js +140 -0
- package/cli/hooks/pre-tool-use.js +186 -0
- package/cli/index.js +90 -0
- package/cli/providers/llm-provider.js +766 -0
- package/cli/utils/autofix-rules.js +74 -0
- package/cli/utils/cache-manager.js +310 -0
- package/cli/utils/compliance-map.js +191 -0
- package/cli/utils/entropy.js +132 -0
- package/cli/utils/fix-ledger.js +127 -0
- package/cli/utils/hermes-tool-registry.js +252 -0
- package/cli/utils/intel/cache.js +61 -0
- package/cli/utils/intel/http.js +88 -0
- package/cli/utils/intel/index.js +235 -0
- package/cli/utils/intel/merge.js +229 -0
- package/cli/utils/intel/sources/epss.js +54 -0
- package/cli/utils/intel/sources/ghsa.js +81 -0
- package/cli/utils/intel/sources/gitguardian.js +40 -0
- package/cli/utils/intel/sources/gitleaks.js +101 -0
- package/cli/utils/intel/sources/kev.js +38 -0
- package/cli/utils/intel/sources/nvd.js +84 -0
- package/cli/utils/intel/sources/osv.js +132 -0
- package/cli/utils/intel/sources/phylum.js +44 -0
- package/cli/utils/intel/sources/snyk.js +46 -0
- package/cli/utils/intel/sources/socket.js +69 -0
- package/cli/utils/intel/sources/sonatype.js +84 -0
- package/cli/utils/intel/sources/threatpack.js +69 -0
- package/cli/utils/mcp-trust.js +60 -0
- package/cli/utils/output.js +251 -0
- package/cli/utils/patterns.js +1130 -0
- package/cli/utils/pdf-generator.js +94 -0
- package/cli/utils/plugin-loader.js +364 -0
- package/cli/utils/rule-import.js +228 -0
- package/cli/utils/rule-registry.js +426 -0
- package/cli/utils/scan-fingerprint.js +109 -0
- package/cli/utils/scan-playbook.js +312 -0
- package/cli/utils/score-history.js +119 -0
- package/cli/utils/secrets-verifier.js +247 -0
- package/cli/utils/security-memory.js +296 -0
- package/cli/utils/standards/atlas-knowledge.js +87 -0
- package/cli/utils/standards/index.js +127 -0
- package/cli/utils/standards/sources/avid.js +45 -0
- package/cli/utils/standards/sources/eu-ai-act.js +89 -0
- package/cli/utils/standards/sources/google-saif.js +39 -0
- package/cli/utils/standards/sources/iso-42001.js +94 -0
- package/cli/utils/standards/sources/mitre-atlas.js +54 -0
- package/cli/utils/standards/sources/nist-ai-600-1.js +45 -0
- package/cli/utils/standards/sources/owasp-llm.js +45 -0
- package/cli/utils/standards/sources/owasp-ml.js +45 -0
- package/cli/utils/threat-intel.js +265 -0
- package/configs/firebase/firestore-rules.txt +215 -0
- package/configs/firebase/security-checklist.md +236 -0
- package/configs/firebase/storage-rules.txt +206 -0
- package/configs/gitignore-template +258 -0
- package/configs/nextjs-security-headers.js +220 -0
- package/configs/praxisignore-template +50 -0
- package/configs/supabase/secure-client.ts +225 -0
- package/configs/supabase/security-checklist.md +278 -0
- package/docs/THIRD_PARTY_NOTICES.md +26 -0
- package/docs/THREAT_INTEL.md +292 -0
- package/docs/USAGE.md +1205 -0
- package/docs/design/WEB-UI.md +82 -0
- package/package.json +71 -0
- package/scripts/check-determinism.mjs +119 -0
- package/snippets/README.md +122 -0
- package/snippets/api-security/api-security-checklist.md +412 -0
- package/snippets/api-security/cors-config.ts +322 -0
- package/snippets/api-security/input-validation.ts +430 -0
- package/snippets/auth/jwt-checklist.md +322 -0
- package/snippets/rate-limiting/nextjs-middleware.ts +211 -0
- 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
|
+
}
|