praxis-sec 1.2.0 → 1.2.1

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 (49) hide show
  1. package/cli/agents/abom-generator.js +1 -1
  2. package/cli/agents/agent-attestation-agent.js +10 -1
  3. package/cli/agents/agent-config-scanner.js +1 -1
  4. package/cli/agents/ai-infra-inventory-agent.js +482 -482
  5. package/cli/agents/base-agent.js +1 -1
  6. package/cli/agents/endpoint-agent-abuse-agent.js +1 -1
  7. package/cli/agents/html-reporter.js +3 -3
  8. package/cli/agents/index.js +2 -2
  9. package/cli/agents/injection-tester.js +8 -1
  10. package/cli/agents/memory-poisoning-agent.js +1 -1
  11. package/cli/agents/model-file-scanner.js +1 -1
  12. package/cli/agents/orchestrator.js +11 -6
  13. package/cli/agents/prompt-injection-prober.js +228 -228
  14. package/cli/bin/praxis.js +7 -3
  15. package/cli/commands/agent-fix.js +1091 -1245
  16. package/cli/commands/audit.js +1228 -1216
  17. package/cli/commands/baseline.js +1 -1
  18. package/cli/commands/benchmark.js +1 -1
  19. package/cli/commands/ci.js +45 -21
  20. package/cli/commands/deps.js +11 -5
  21. package/cli/commands/env-audit.js +1 -1
  22. package/cli/commands/fix.js +1 -1
  23. package/cli/commands/mcp.js +1 -1
  24. package/cli/commands/red-team.js +350 -350
  25. package/cli/commands/remediate.js +1 -1
  26. package/cli/commands/rotate.js +1 -1
  27. package/cli/commands/rules.js +1 -1
  28. package/cli/commands/scan.js +554 -554
  29. package/cli/commands/score.js +1 -1
  30. package/cli/commands/undo.js +22 -77
  31. package/cli/commands/vibe-check.js +1 -1
  32. package/cli/core/fix-plan.js +274 -0
  33. package/cli/core/fs.js +27 -0
  34. package/cli/core/git-clone.js +8 -6
  35. package/cli/core/glob.js +56 -0
  36. package/cli/core/output/html-theme.js +158 -158
  37. package/cli/core/output/sarif.js +2 -2
  38. package/cli/core/web/jobs.js +2 -2
  39. package/cli/core/web/server.js +19 -8
  40. package/cli/data/threatpacks/latest.json +41 -41
  41. package/cli/integrations/github-action.js +136 -0
  42. package/cli/utils/plugin-loader.js +15 -95
  43. package/cli/utils/rule-import.js +227 -227
  44. package/cli/utils/rule-registry.js +425 -425
  45. package/cli/utils/scan-fingerprint.js +1 -1
  46. package/cli/utils/score-history.js +118 -118
  47. package/docs/USAGE.md +16 -9
  48. package/docs/design/WEB-UI.md +4 -5
  49. package/package.json +13 -4
@@ -0,0 +1,136 @@
1
+ /** Marketplace runner: argv-only execution, release-pinned CLI, and one gate decision. */
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import { spawnSync, execFileSync } from 'child_process';
6
+ import { fileURLToPath } from 'url';
7
+
8
+ const cli = fileURLToPath(new URL('../bin/praxis.js', import.meta.url));
9
+ const severities = ['critical', 'high', 'medium', 'low'];
10
+
11
+ export function buildScanArgs(inputs, { base = false, sarif = null } = {}) {
12
+ const args = ['scan', 'ci', inputs.path || '.', '--json', '--threshold', base ? '0' : (inputs.threshold || '75')];
13
+ if (inputs.deps === 'false') args.push('--no-deps');
14
+ if (inputs.deep === 'true') args.push('--deep');
15
+ if (!base && inputs.baseline === 'true') args.push('--baseline');
16
+ if (!base && inputs.failOn) args.push('--fail-on', inputs.failOn);
17
+ if (!base && inputs.alwaysFailOn) args.push('--always-fail-on', inputs.alwaysFailOn);
18
+ if (inputs.netNew === 'true') args.push('--include-findings');
19
+ if (sarif) args.push('--sarif', sarif);
20
+ return args;
21
+ }
22
+
23
+ export function parseScanResult(result) {
24
+ if (result.error) throw result.error;
25
+ if (![0, 1].includes(result.status)) throw new Error(`Praxis scan failed (exit ${result.status})`);
26
+ let report;
27
+ try { report = JSON.parse(result.stdout); } catch { throw new Error('Praxis scan did not produce valid JSON'); } // praxis-ignore AGENT_NO_OUTPUT_SCHEMA — subprocess stdout, not LLM output; fields are checked on the next line
28
+ if (!report || typeof report.pass !== 'boolean' || !Number.isFinite(report.score)) {
29
+ throw new Error('Praxis scan produced an invalid report');
30
+ }
31
+ if (result.status === 1 && report.pass) throw new Error('Praxis scan exit status disagrees with report');
32
+ return report;
33
+ }
34
+
35
+ export function introducedFindings(base, head, floor = 'high') {
36
+ const limit = severities.indexOf(floor);
37
+ if (limit < 0) throw new Error(`Invalid fail-on-new severity: ${floor}`);
38
+ if (!Array.isArray(base.findings) || !Array.isArray(head.findings)) throw new Error('Net-new gate requires finding lists from both scans');
39
+ const identity = finding => `${finding.file}:${finding.rule}`;
40
+ const existing = new Set(base.findings.map(identity));
41
+ const introduced = head.findings.filter(finding => !existing.has(identity(finding)));
42
+ return {
43
+ total: introduced.length,
44
+ blocking: introduced.filter(finding => {
45
+ const severity = severities.indexOf(finding.severity);
46
+ return severity >= 0 && severity <= limit;
47
+ }).length,
48
+ };
49
+ }
50
+
51
+ export function evaluateGate(head, base, inputs) {
52
+ if (!base) return { pass: head.pass, introduced: null };
53
+ if (base.scanComplete === false) throw new Error('Base scan is incomplete; refusing to pass the net-new gate');
54
+ const introduced = introducedFindings(base, head, inputs.failOnNew || 'high');
55
+ return { pass: head.scanComplete !== false && head.floorPass !== false && introduced.blocking === 0, introduced };
56
+ }
57
+
58
+ function scan(inputs, cwd, options = {}) {
59
+ const result = spawnSync(process.execPath, [cli, ...buildScanArgs(inputs, options)], {
60
+ cwd, env: process.env, encoding: 'utf8', timeout: 20 * 60 * 1000, maxBuffer: 32 * 1024 * 1024,
61
+ });
62
+ // GitHub annotations go to stderr, keeping the JSON channel valid.
63
+ if (result.stderr) process.stderr.write(result.stderr);
64
+ return parseScanResult(result);
65
+ }
66
+
67
+ export async function runAction(env = process.env) {
68
+ const inputs = {
69
+ path: env.PRAXIS_INPUT_PATH, threshold: env.PRAXIS_INPUT_THRESHOLD,
70
+ failOn: env.PRAXIS_INPUT_FAIL_ON, alwaysFailOn: env.PRAXIS_INPUT_ALWAYS_FAIL_ON,
71
+ deps: env.PRAXIS_INPUT_DEPS, deep: env.PRAXIS_INPUT_DEEP, baseline: env.PRAXIS_INPUT_BASELINE,
72
+ netNew: env.PRAXIS_INPUT_NET_NEW, failOnNew: env.PRAXIS_INPUT_FAIL_ON_NEW,
73
+ };
74
+ const temp = fs.mkdtempSync(path.join(env.RUNNER_TEMP || os.tmpdir(), 'praxis-action-'));
75
+ const sarif = env.PRAXIS_INPUT_SARIF === 'true' ? path.join(temp, 'results.sarif') : null;
76
+ const root = env.GITHUB_WORKSPACE || process.cwd();
77
+ const head = scan(inputs, root, { sarif });
78
+ let base = null;
79
+ if (inputs.netNew === 'true') {
80
+ if (env.GITHUB_EVENT_NAME !== 'pull_request') throw new Error('net-new requires a pull_request event');
81
+ const sha = env.PRAXIS_BASE_SHA || '';
82
+ if (!/^[a-f0-9]{40,64}$/i.test(sha)) throw new Error('Invalid or missing pull request base SHA');
83
+ const worktree = path.join(temp, 'base');
84
+ let added = false;
85
+ try {
86
+ execFileSync('git', ['fetch', '--depth', '1', 'origin', sha], { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] });
87
+ execFileSync('git', ['worktree', 'add', '--detach', worktree, sha], { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] });
88
+ added = true;
89
+ base = scan(inputs, worktree, { base: true });
90
+ } finally {
91
+ if (added) execFileSync('git', ['worktree', 'remove', '--force', worktree], { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] });
92
+ }
93
+ }
94
+ const gate = evaluateGate(head, base, inputs);
95
+ const count = category => head.categories?.[category]?.findingCount || 0;
96
+ const outputs = {
97
+ score: head.score, grade: head.grade, findings: head.totalFindings, secrets: count('secrets'),
98
+ vulns: count('injection') + count('auth'), cves: head.totalDepVulns,
99
+ 'sarif-file': sarif && fs.existsSync(sarif) ? sarif : '',
100
+ 'report-url': `${env.GITHUB_SERVER_URL || 'https://github.com'}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}`,
101
+ fail: gate.pass ? '0' : '1',
102
+ };
103
+ if (env.GITHUB_OUTPUT) {
104
+ fs.appendFileSync(env.GITHUB_OUTPUT, Object.entries(outputs).map(([key, value]) => `${key}=${value ?? ''}`).join('\n') + '\n');
105
+ }
106
+ let summary = `## Praxis Security Report\n\nScore: **${head.score}/100 (${head.grade})** · Findings: **${head.totalFindings}** · CVEs: **${head.totalDepVulns}**\n\n`;
107
+ if (gate.introduced) summary += `Net-new gate: **${gate.introduced.total}** introduced finding(s), **${gate.introduced.blocking}** at or above ${inputs.failOnNew || 'high'}.\n\n`;
108
+ summary += gate.pass ? '**Passed** the configured security gate.\n' : '**Failed** the configured security gate.\n';
109
+ if (env.GITHUB_STEP_SUMMARY) fs.appendFileSync(env.GITHUB_STEP_SUMMARY, summary);
110
+ console.log(summary);
111
+ if (env.PRAXIS_INPUT_COMMENT === 'true' && env.GITHUB_EVENT_NAME === 'pull_request') {
112
+ const pr = env.PRAXIS_PR_NUMBER;
113
+ const repository = env.GITHUB_REPOSITORY;
114
+ if (!/^\d+$/.test(pr || '') || !/^[\w.-]+\/[\w.-]+$/.test(repository || '')) throw new Error('Invalid PR context');
115
+ const marker = '<!-- praxis-security-report -->';
116
+ const body = `${marker}\n${summary}\n[Scanned by Praxis](https://github.com/Ganron007/Praxis)`;
117
+ const bodyPath = path.join(temp, 'comment.json');
118
+ fs.writeFileSync(bodyPath, JSON.stringify({ body }));
119
+ try {
120
+ const comments = JSON.parse(execFileSync('gh', ['api', '--paginate', '--slurp', `repos/${repository}/issues/${pr}/comments`], { env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })).flat();
121
+ const existing = comments.find(comment => comment.user?.type === 'Bot' && comment.body?.includes(marker));
122
+ const endpoint = existing ? `repos/${repository}/issues/comments/${existing.id}` : `repos/${repository}/issues/${pr}/comments`;
123
+ execFileSync('gh', ['api', endpoint, '-X', existing ? 'PATCH' : 'POST', '--input', bodyPath], { env, stdio: ['ignore', 'pipe', 'pipe'] });
124
+ } catch {
125
+ console.error('::warning::Could not post Praxis PR comment. Check pull-requests: write permissions.');
126
+ }
127
+ }
128
+ return gate;
129
+ }
130
+
131
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
132
+ runAction().catch(error => {
133
+ console.error(`::error::${String(error.message).replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A')}`);
134
+ process.exitCode = 1;
135
+ });
136
+ }
@@ -3,10 +3,10 @@
3
3
  * ============================================
4
4
  *
5
5
  * Allows users to drop custom security agents into `.praxis/agents/` and
6
- * have them automatically loaded and run alongside the built-in agents.
6
+ * run them alongside the built-in agents after explicit local trust.
7
7
  *
8
8
  * HOW IT WORKS:
9
- * 1. On startup, loadPlugins(rootPath) scans `.praxis/agents/*.js`
9
+ * 1. With explicit trust, loadPlugins(rootPath) scans `.praxis/agents/*.js`
10
10
  * 2. Each file must export a default class that extends BaseAgent
11
11
  * 3. Validated plugins are instantiated and returned for registration
12
12
  * 4. buildOrchestrator() calls loadPlugins() and registers the results
@@ -21,7 +21,7 @@
21
21
  * EXAMPLE PLUGIN:
22
22
  *
23
23
  * // .praxis/agents/my-rule.js
24
- * import { BaseAgent, createFinding } from 'praxis';
24
+ * import { BaseAgent, createFinding } from 'praxis-sec';
25
25
  *
26
26
  * export default class MyCustomRule extends BaseAgent {
27
27
  * constructor() {
@@ -50,12 +50,12 @@
50
50
  * }
51
51
  *
52
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.
53
+ * Trusted plugins run in the same process with full operator permissions.
54
+ * Agent promise timeouts are not a sandbox and cannot stop synchronous loops.
55
55
  *
56
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.
57
+ * Plugins are arbitrary code with full operator permissions. A normal scan
58
+ * never imports them; trusted local scans opt in with --trust-plugins.
59
59
  */
60
60
 
61
61
  import fs from 'fs';
@@ -69,10 +69,13 @@ const PLUGIN_DIR = '.praxis/agents';
69
69
  * Load custom agent plugins from .praxis/agents/*.js
70
70
  *
71
71
  * @param {string} rootPath — project root directory
72
- * @param {object} options — { verbose, quiet }
72
+ * @param {object} options — { verbose, quiet, trustPlugins }
73
73
  * @returns {Promise<object[]>} — array of instantiated agent objects
74
74
  */
75
75
  export async function loadPlugins(rootPath, options = {}) {
76
+ // Importing a module executes its top-level code before class validation.
77
+ // Scanned repositories are data, never implicit authorization to run plugins.
78
+ if (options.trustPlugins !== true) return [];
76
79
  const pluginDir = path.join(rootPath, PLUGIN_DIR);
77
80
 
78
81
  if (!fs.existsSync(pluginDir)) return [];
@@ -126,89 +129,6 @@ export async function loadPlugins(rootPath, options = {}) {
126
129
  instance.category = 'custom';
127
130
  }
128
131
 
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
132
  plugins.push(instance);
213
133
 
214
134
  if (!options.quiet) {
@@ -283,8 +203,8 @@ export function scaffoldPlugin(rootPath, pluginName) {
283
203
  const template = `/**
284
204
  * Custom Praxis Agent: ${className}
285
205
  *
286
- * Drop this file in .praxis/agents/ to have it run automatically
287
- * as part of every \`praxis audit\` or \`praxis watch --deep\`.
206
+ * Drop this file in .praxis/agents/ and explicitly opt in with
207
+ * \`praxis scan full . --trust-plugins\` after reviewing its code.
288
208
  *
289
209
  * The \`analyze(context)\` method receives:
290
210
  * context.rootPath — absolute path to the project root
@@ -304,9 +224,9 @@ if (globalThis.__praxisAgentFramework) {
304
224
  ({ BaseAgent, createFinding } = globalThis.__praxisAgentFramework);
305
225
  } else {
306
226
  try {
307
- ({ BaseAgent, createFinding } = await import('praxis'));
227
+ ({ BaseAgent, createFinding } = await import('praxis-sec'));
308
228
  } catch {
309
- ({ BaseAgent, createFinding } = await import('praxis/cli/index.js'));
229
+ ({ BaseAgent, createFinding } = await import('praxis-sec/cli/index.js'));
310
230
  }
311
231
  }
312
232