praxis-sec 1.2.0 → 1.2.2

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 (60) hide show
  1. package/assets/praxis-architecture.svg +304 -0
  2. package/assets/praxis-logo.svg +38 -0
  3. package/cli/agents/abom-generator.js +1 -1
  4. package/cli/agents/agent-attestation-agent.js +10 -1
  5. package/cli/agents/agent-config-scanner.js +1 -1
  6. package/cli/agents/ai-infra-inventory-agent.js +482 -482
  7. package/cli/agents/base-agent.js +1 -1
  8. package/cli/agents/endpoint-agent-abuse-agent.js +1 -1
  9. package/cli/agents/html-reporter.js +10 -10
  10. package/cli/agents/index.js +2 -2
  11. package/cli/agents/injection-tester.js +8 -1
  12. package/cli/agents/mcp-security-agent.js +600 -594
  13. package/cli/agents/memory-poisoning-agent.js +1 -1
  14. package/cli/agents/model-file-scanner.js +1 -1
  15. package/cli/agents/orchestrator.js +375 -355
  16. package/cli/agents/prompt-injection-prober.js +228 -228
  17. package/cli/bin/praxis.js +7 -3
  18. package/cli/commands/agent-fix.js +1091 -1245
  19. package/cli/commands/audit.js +1232 -1216
  20. package/cli/commands/baseline.js +3 -2
  21. package/cli/commands/benchmark.js +1 -1
  22. package/cli/commands/ci.js +50 -25
  23. package/cli/commands/deps.js +11 -5
  24. package/cli/commands/diff.js +2 -1
  25. package/cli/commands/env-audit.js +1 -1
  26. package/cli/commands/fix.js +1 -1
  27. package/cli/commands/legal.js +2 -1
  28. package/cli/commands/mcp.js +3 -2
  29. package/cli/commands/openclaw.js +3 -6
  30. package/cli/commands/red-team.js +351 -350
  31. package/cli/commands/remediate.js +1 -1
  32. package/cli/commands/rotate.js +1 -1
  33. package/cli/commands/rules.js +1 -1
  34. package/cli/commands/scan-standard.js +3 -6
  35. package/cli/commands/scan.js +554 -554
  36. package/cli/commands/score.js +1 -1
  37. package/cli/commands/undo.js +22 -77
  38. package/cli/commands/vibe-check.js +3 -2
  39. package/cli/commands/watch.js +6 -5
  40. package/cli/core/fix-plan.js +274 -0
  41. package/cli/core/fs.js +27 -0
  42. package/cli/core/git-clone.js +8 -6
  43. package/cli/core/glob.js +56 -0
  44. package/cli/core/output/html-theme.js +158 -158
  45. package/cli/core/output/json.js +56 -48
  46. package/cli/core/output/sarif.js +2 -2
  47. package/cli/core/paths.js +91 -0
  48. package/cli/core/web/jobs.js +2 -2
  49. package/cli/core/web/server.js +19 -8
  50. package/cli/data/threatpacks/latest.json +41 -41
  51. package/cli/integrations/github-action.js +136 -0
  52. package/cli/utils/cache-manager.js +2 -1
  53. package/cli/utils/plugin-loader.js +15 -95
  54. package/cli/utils/rule-import.js +227 -227
  55. package/cli/utils/rule-registry.js +425 -425
  56. package/cli/utils/scan-fingerprint.js +1 -1
  57. package/cli/utils/score-history.js +118 -118
  58. package/docs/USAGE.md +16 -9
  59. package/docs/design/WEB-UI.md +4 -5
  60. package/package.json +81 -71
@@ -1,228 +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;
1
+ /**
2
+ * Portable rule import — the inverse of the export.
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
228
  }