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.
- package/cli/agents/abom-generator.js +1 -1
- package/cli/agents/agent-attestation-agent.js +10 -1
- package/cli/agents/agent-config-scanner.js +1 -1
- package/cli/agents/ai-infra-inventory-agent.js +482 -482
- package/cli/agents/base-agent.js +1 -1
- package/cli/agents/endpoint-agent-abuse-agent.js +1 -1
- package/cli/agents/html-reporter.js +3 -3
- package/cli/agents/index.js +2 -2
- package/cli/agents/injection-tester.js +8 -1
- package/cli/agents/memory-poisoning-agent.js +1 -1
- package/cli/agents/model-file-scanner.js +1 -1
- package/cli/agents/orchestrator.js +11 -6
- package/cli/agents/prompt-injection-prober.js +228 -228
- package/cli/bin/praxis.js +7 -3
- package/cli/commands/agent-fix.js +1091 -1245
- package/cli/commands/audit.js +1228 -1216
- package/cli/commands/baseline.js +1 -1
- package/cli/commands/benchmark.js +1 -1
- package/cli/commands/ci.js +45 -21
- package/cli/commands/deps.js +11 -5
- package/cli/commands/env-audit.js +1 -1
- package/cli/commands/fix.js +1 -1
- package/cli/commands/mcp.js +1 -1
- package/cli/commands/red-team.js +350 -350
- package/cli/commands/remediate.js +1 -1
- package/cli/commands/rotate.js +1 -1
- package/cli/commands/rules.js +1 -1
- package/cli/commands/scan.js +554 -554
- package/cli/commands/score.js +1 -1
- package/cli/commands/undo.js +22 -77
- package/cli/commands/vibe-check.js +1 -1
- package/cli/core/fix-plan.js +274 -0
- package/cli/core/fs.js +27 -0
- package/cli/core/git-clone.js +8 -6
- package/cli/core/glob.js +56 -0
- package/cli/core/output/html-theme.js +158 -158
- package/cli/core/output/sarif.js +2 -2
- package/cli/core/web/jobs.js +2 -2
- package/cli/core/web/server.js +19 -8
- package/cli/data/threatpacks/latest.json +41 -41
- package/cli/integrations/github-action.js +136 -0
- package/cli/utils/plugin-loader.js +15 -95
- package/cli/utils/rule-import.js +227 -227
- package/cli/utils/rule-registry.js +425 -425
- package/cli/utils/scan-fingerprint.js +1 -1
- package/cli/utils/score-history.js +118 -118
- package/docs/USAGE.md +16 -9
- package/docs/design/WEB-UI.md +4 -5
- 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
|
-
*
|
|
6
|
+
* run them alongside the built-in agents after explicit local trust.
|
|
7
7
|
*
|
|
8
8
|
* HOW IT WORKS:
|
|
9
|
-
* 1.
|
|
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
|
-
*
|
|
54
|
-
*
|
|
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
|
|
58
|
-
*
|
|
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/
|
|
287
|
-
*
|
|
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
|
|