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,158 +1,158 @@
1
- /**
2
- * Shared HTML report theme — single source of truth for every Praxis HTML surface.
3
- * ============================================================================
4
- *
5
- * Extracted (P-IMP-051c) because `cli/agents/html-reporter.js` and
6
- * `cli/commands/team-report.js` each carried their own ~100-line inline stylesheet
7
- * and their own severity palette. Those palettes had already drifted
8
- * (`critical` was `#ef4444` in one and `#dc2626` in the other), so every brand or
9
- * styling fix had to be applied twice — and the two files disagreed on the same
10
- * concept.
11
- *
12
- * Also centralises the primitives that must never be re-implemented per report:
13
- * - `esc()` — HTML escaping. `team-report.js` previously had none and
14
- * interpolated finding text straight into markup, which is
15
- * an injection sink in a file users open in a browser.
16
- * - `severityBadgeClass()` — maps a severity to a *known* CSS class, so an arbitrary
17
- * severity string can't be injected into a class attribute.
18
- * - `countBySeverity()` — one counting implementation, not one per report.
19
- *
20
- * Design constraints (per AGENTS.md): no build step, no bundler, no framework. These
21
- * are plain template strings and functions, so every report stays a single
22
- * self-contained offline HTML file.
23
- */
24
-
25
- /** Severity → accent colour. Used for bars, legends, and inline accents. */
26
- export const SEVERITY_COLORS = {
27
- critical: '#ef4444',
28
- high: '#f97316',
29
- medium: '#eab308',
30
- low: '#38bdf8',
31
- info: '#64748b',
32
- };
33
-
34
- /** Security-score letter grade → colour. */
35
- export const GRADE_COLORS = {
36
- A: '#22c55e',
37
- B: '#06b6d4',
38
- C: '#eab308',
39
- D: '#f97316',
40
- F: '#ef4444',
41
- };
42
-
43
- /** Every severity we recognise, in report order. */
44
- export const SEVERITIES = ['critical', 'high', 'medium', 'low'];
45
-
46
- /**
47
- * Severities rendered in distribution bars/legends. Includes `info` so the segments
48
- * always sum to 100% of the findings shown — omitting it left the bar visibly
49
- * under-filled whenever a scan produced informational findings.
50
- */
51
- export const DISPLAY_SEVERITIES = ['critical', 'high', 'medium', 'low', 'info'];
52
-
53
- /**
54
- * Escapes text for interpolation into HTML markup or a quoted attribute.
55
- * Null/undefined become an empty string so callers can interpolate directly.
56
- */
57
- export function esc(str) {
58
- if (str === null || str === undefined) return '';
59
- return String(str)
60
- .replace(/&/g, '&')
61
- .replace(/</g, '&lt;')
62
- .replace(/>/g, '&gt;')
63
- .replace(/"/g, '&quot;')
64
- .replace(/'/g, '&#039;');
65
- }
66
-
67
- /**
68
- * Maps a severity to a known-safe CSS class suffix.
69
- *
70
- * Returns `''` for anything unrecognised so the result is always a safe subset of
71
- * the class attribute — a finding whose `severity` came from a parsed report file
72
- * must never be able to break out of `class="sev-badge …"`.
73
- */
74
- export function severityBadgeClass(severity) {
75
- const key = String(severity || '').toLowerCase();
76
- return SEVERITIES.includes(key) || key === 'info' ? key : '';
77
- }
78
-
79
- /** Renders a severity badge span. Escapes the label and sanitises the class. */
80
- export function severityBadge(severity, label) {
81
- const cls = severityBadgeClass(severity);
82
- const text = label ?? String(severity || 'unknown');
83
- const classes = cls ? `sev-badge sev-${cls}` : 'sev-badge';
84
- return `<span class="${classes}">${esc(text)}</span>`;
85
- }
86
-
87
- /** Counts findings per severity, always returning a fully-populated record. */
88
- export function countBySeverity(findings = []) {
89
- const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
90
- for (const f of Array.isArray(findings) ? findings : []) {
91
- const key = String(f?.severity || '').toLowerCase();
92
- if (key in counts) counts[key]++;
93
- }
94
- return counts;
95
- }
96
-
97
- /**
98
- * The shared base stylesheet: reset, typography, layout container, tables, code,
99
- * severity badges, and the muted/empty/footer primitives. Every report includes
100
- * this, then appends its own component styles.
101
- */
102
- export function baseStyles() {
103
- return `
104
- *{margin:0;padding:0;box-sizing:border-box}
105
- html{scroll-behavior:smooth}
106
- body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;background:#090d16;color:#cbd5e1;line-height:1.55;font-size:14px}
107
- a{color:#38bdf8;text-decoration:none}
108
- a:hover{text-decoration:underline}
109
-
110
- .container{max-width:1440px;margin:1.8rem auto;padding:0 2rem}
111
- h1{font-size:1.8rem;font-weight:700;color:#f8fafc;margin-bottom:0.25rem}
112
- h2{font-size:1.1rem;font-weight:600;margin:2rem 0 1rem;color:#94a3b8;border-bottom:1px solid #1e293b;padding-bottom:0.5rem;text-transform:uppercase;letter-spacing:0.05em}
113
-
114
- .table-responsive{overflow-x:auto}
115
- table{width:100%;border-collapse:collapse;font-size:0.86rem;text-align:left}
116
- th{background:#131d33;color:#94a3b8;padding:0.75rem 1rem;font-size:0.75rem;text-transform:uppercase;letter-spacing:0.6px;border-bottom:1px solid #1e293b}
117
- td{padding:0.75rem 1rem;border-bottom:1px solid #172239;vertical-align:top}
118
- tr:hover td{background:#111b30}
119
- code{background:#050811;border:1px solid #1e293b;color:#7dd3fc;padding:2px 6px;border-radius:4px;font-size:0.8rem;word-break:break-word}
120
- small{color:#64748b}
121
-
122
- .sev-badge{display:inline-block;padding:3px 9px;border-radius:6px;font-size:0.72rem;font-weight:800;text-transform:uppercase;letter-spacing:0.5px}
123
- .sev-critical{background:#450a0a;color:#fca5a5;border:1px solid #991b1b}
124
- .sev-high{background:#431407;color:#fdba74;border:1px solid #9a3412}
125
- .sev-medium{background:#422006;color:#fde047;border:1px solid #854d0e}
126
- .sev-low{background:#082f49;color:#7dd3fc;border:1px solid #075985}
127
- .sev-info{background:#1e293b;color:#cbd5e1;border:1px solid #334155}
128
-
129
- .muted{color:#64748b}
130
- .ok{color:#6ee7b7}
131
- .fail{color:#fca5a5}
132
- .empty-state{text-align:center;color:#64748b;padding:2.5rem 1rem;font-size:0.9rem}
133
- .footer{text-align:center;padding:2.5rem 0 1.5rem;color:#64748b;font-size:0.8rem;border-top:1px solid #1e293b;margin-top:3rem}
134
- @media(max-width:1024px){.container{padding:0 1rem}}
135
- `;
136
- }
137
-
138
- /**
139
- * Wraps body markup in a complete, self-contained HTML document.
140
- *
141
- * `styles` is injected verbatim and must be trusted (it is always generated by
142
- * this module, never derived from scan findings). `body` and `title` are escaped.
143
- */
144
- export function documentShell({ title, styles, body, bodyClass = '' }) {
145
- const cls = bodyClass ? ` class="${esc(bodyClass)}"` : '';
146
- return `<!DOCTYPE html>
147
- <html lang="en">
148
- <head>
149
- <meta charset="utf-8">
150
- <meta name="viewport" content="width=device-width,initial-scale=1">
151
- <title>${esc(title)}</title>
152
- <style>${styles}</style>
153
- </head>
154
- <body${cls}>
155
- ${body}
156
- </body>
157
- </html>`;
158
- }
1
+ /**
2
+ * Shared HTML report theme — single source of truth for every Praxis HTML surface.
3
+ * ============================================================================
4
+ *
5
+ * Extracted because `cli/agents/html-reporter.js` and
6
+ * `cli/commands/team-report.js` each carried their own ~100-line inline stylesheet
7
+ * and their own severity palette. Those palettes had already drifted
8
+ * (`critical` was `#ef4444` in one and `#dc2626` in the other), so every brand or
9
+ * styling fix had to be applied twice — and the two files disagreed on the same
10
+ * concept.
11
+ *
12
+ * Also centralises the primitives that must never be re-implemented per report:
13
+ * - `esc()` — HTML escaping. `team-report.js` previously had none and
14
+ * interpolated finding text straight into markup, which is
15
+ * an injection sink in a file users open in a browser.
16
+ * - `severityBadgeClass()` — maps a severity to a *known* CSS class, so an arbitrary
17
+ * severity string can't be injected into a class attribute.
18
+ * - `countBySeverity()` — one counting implementation, not one per report.
19
+ *
20
+ * Design constraints (per AGENTS.md): no build step, no bundler, no framework. These
21
+ * are plain template strings and functions, so every report stays a single
22
+ * self-contained offline HTML file.
23
+ */
24
+
25
+ /** Severity → accent colour. Used for bars, legends, and inline accents. */
26
+ export const SEVERITY_COLORS = {
27
+ critical: '#ef4444',
28
+ high: '#f97316',
29
+ medium: '#eab308',
30
+ low: '#38bdf8',
31
+ info: '#64748b',
32
+ };
33
+
34
+ /** Security-score letter grade → colour. */
35
+ export const GRADE_COLORS = {
36
+ A: '#22c55e',
37
+ B: '#06b6d4',
38
+ C: '#eab308',
39
+ D: '#f97316',
40
+ F: '#ef4444',
41
+ };
42
+
43
+ /** Every severity we recognise, in report order. */
44
+ export const SEVERITIES = ['critical', 'high', 'medium', 'low'];
45
+
46
+ /**
47
+ * Severities rendered in distribution bars/legends. Includes `info` so the segments
48
+ * always sum to 100% of the findings shown — omitting it left the bar visibly
49
+ * under-filled whenever a scan produced informational findings.
50
+ */
51
+ export const DISPLAY_SEVERITIES = ['critical', 'high', 'medium', 'low', 'info'];
52
+
53
+ /**
54
+ * Escapes text for interpolation into HTML markup or a quoted attribute.
55
+ * Null/undefined become an empty string so callers can interpolate directly.
56
+ */
57
+ export function esc(str) {
58
+ if (str === null || str === undefined) return '';
59
+ return String(str)
60
+ .replace(/&/g, '&amp;')
61
+ .replace(/</g, '&lt;')
62
+ .replace(/>/g, '&gt;')
63
+ .replace(/"/g, '&quot;')
64
+ .replace(/'/g, '&#039;');
65
+ }
66
+
67
+ /**
68
+ * Maps a severity to a known-safe CSS class suffix.
69
+ *
70
+ * Returns `''` for anything unrecognised so the result is always a safe subset of
71
+ * the class attribute — a finding whose `severity` came from a parsed report file
72
+ * must never be able to break out of `class="sev-badge …"`.
73
+ */
74
+ export function severityBadgeClass(severity) {
75
+ const key = String(severity || '').toLowerCase();
76
+ return SEVERITIES.includes(key) || key === 'info' ? key : '';
77
+ }
78
+
79
+ /** Renders a severity badge span. Escapes the label and sanitises the class. */
80
+ export function severityBadge(severity, label) {
81
+ const cls = severityBadgeClass(severity);
82
+ const text = label ?? String(severity || 'unknown');
83
+ const classes = cls ? `sev-badge sev-${cls}` : 'sev-badge';
84
+ return `<span class="${classes}">${esc(text)}</span>`;
85
+ }
86
+
87
+ /** Counts findings per severity, always returning a fully-populated record. */
88
+ export function countBySeverity(findings = []) {
89
+ const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
90
+ for (const f of Array.isArray(findings) ? findings : []) {
91
+ const key = String(f?.severity || '').toLowerCase();
92
+ if (key in counts) counts[key]++;
93
+ }
94
+ return counts;
95
+ }
96
+
97
+ /**
98
+ * The shared base stylesheet: reset, typography, layout container, tables, code,
99
+ * severity badges, and the muted/empty/footer primitives. Every report includes
100
+ * this, then appends its own component styles.
101
+ */
102
+ export function baseStyles() {
103
+ return `
104
+ *{margin:0;padding:0;box-sizing:border-box}
105
+ html{scroll-behavior:smooth}
106
+ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;background:#090d16;color:#cbd5e1;line-height:1.55;font-size:14px}
107
+ a{color:#38bdf8;text-decoration:none}
108
+ a:hover{text-decoration:underline}
109
+
110
+ .container{max-width:1440px;margin:1.8rem auto;padding:0 2rem}
111
+ h1{font-size:1.8rem;font-weight:700;color:#f8fafc;margin-bottom:0.25rem}
112
+ h2{font-size:1.1rem;font-weight:600;margin:2rem 0 1rem;color:#94a3b8;border-bottom:1px solid #1e293b;padding-bottom:0.5rem;text-transform:uppercase;letter-spacing:0.05em}
113
+
114
+ .table-responsive{overflow-x:auto}
115
+ table{width:100%;border-collapse:collapse;font-size:0.86rem;text-align:left}
116
+ th{background:#131d33;color:#94a3b8;padding:0.75rem 1rem;font-size:0.75rem;text-transform:uppercase;letter-spacing:0.6px;border-bottom:1px solid #1e293b}
117
+ td{padding:0.75rem 1rem;border-bottom:1px solid #172239;vertical-align:top}
118
+ tr:hover td{background:#111b30}
119
+ code{background:#050811;border:1px solid #1e293b;color:#7dd3fc;padding:2px 6px;border-radius:4px;font-size:0.8rem;word-break:break-word}
120
+ small{color:#64748b}
121
+
122
+ .sev-badge{display:inline-block;padding:3px 9px;border-radius:6px;font-size:0.72rem;font-weight:800;text-transform:uppercase;letter-spacing:0.5px}
123
+ .sev-critical{background:#450a0a;color:#fca5a5;border:1px solid #991b1b}
124
+ .sev-high{background:#431407;color:#fdba74;border:1px solid #9a3412}
125
+ .sev-medium{background:#422006;color:#fde047;border:1px solid #854d0e}
126
+ .sev-low{background:#082f49;color:#7dd3fc;border:1px solid #075985}
127
+ .sev-info{background:#1e293b;color:#cbd5e1;border:1px solid #334155}
128
+
129
+ .muted{color:#64748b}
130
+ .ok{color:#6ee7b7}
131
+ .fail{color:#fca5a5}
132
+ .empty-state{text-align:center;color:#64748b;padding:2.5rem 1rem;font-size:0.9rem}
133
+ .footer{text-align:center;padding:2.5rem 0 1.5rem;color:#64748b;font-size:0.8rem;border-top:1px solid #1e293b;margin-top:3rem}
134
+ @media(max-width:1024px){.container{padding:0 1rem}}
135
+ `;
136
+ }
137
+
138
+ /**
139
+ * Wraps body markup in a complete, self-contained HTML document.
140
+ *
141
+ * `styles` is injected verbatim and must be trusted (it is always generated by
142
+ * this module, never derived from scan findings). `body` and `title` are escaped.
143
+ */
144
+ export function documentShell({ title, styles, body, bodyClass = '' }) {
145
+ const cls = bodyClass ? ` class="${esc(bodyClass)}"` : '';
146
+ return `<!DOCTYPE html>
147
+ <html lang="en">
148
+ <head>
149
+ <meta charset="utf-8">
150
+ <meta name="viewport" content="width=device-width,initial-scale=1">
151
+ <title>${esc(title)}</title>
152
+ <style>${styles}</style>
153
+ </head>
154
+ <body${cls}>
155
+ ${body}
156
+ </body>
157
+ </html>`;
158
+ }
@@ -1,48 +1,56 @@
1
- /**
2
- * JSON output formatter.
3
- *
4
- * Reports are emitted with `schemaVersion` so consumers can pin to a
5
- * version. Bump the version when the shape changes in a breaking way.
6
- *
7
- * Scanner hardening: secret-category findings never expose their raw
8
- * matched value — `matched` is redacted centrally so no consumer of the
9
- * JSON report can leak a credential.
10
- */
11
-
12
- const SCHEMA_VERSION = 3;
13
-
14
- function redactFinding(f) {
15
- if (!f || typeof f !== 'object') return f;
16
- const isSecret = f.category === 'secrets' || f.category === 'secret'
17
- || /secret|api[_-]?key|token|password|credential/i.test(String(f.rule || ''));
18
- const out = { ...f };
19
- if (f.matched) {
20
- out.matched = isSecret
21
- ? `${String(f.matched).slice(0, 3)}***`
22
- : String(f.matched).slice(0, 160);
23
- }
24
- if (out.file) {
25
- out.file = String(out.file)
26
- .replace(/\\/g, '/')
27
- .replace(/^[a-zA-Z]:\/+/, '')
28
- .replace(/^.*\/Praxis\/showcase-target\//, 'showcase-target/')
29
- .replace(/^.*\/Praxis\//, '');
30
- }
31
- return out;
32
- }
33
-
34
- export default function json(report, options = {}) {
35
- const { pretty = true } = options;
36
- const enriched = {
37
- schemaVersion: SCHEMA_VERSION,
38
- ...report,
39
- };
40
- if (Array.isArray(enriched.findings)) {
41
- enriched.findings = enriched.findings.map(redactFinding);
42
- }
43
- return pretty
44
- ? JSON.stringify(enriched, null, 2)
45
- : JSON.stringify(enriched);
46
- }
47
-
48
- export { redactFinding, SCHEMA_VERSION };
1
+ /**
2
+ * JSON output formatter.
3
+ *
4
+ * Reports are emitted with `schemaVersion` so consumers can pin to a
5
+ * version. Bump the version when the shape changes in a breaking way.
6
+ *
7
+ * Scanner hardening: secret-category findings never expose their raw
8
+ * matched value — `matched` is redacted centrally so no consumer of the
9
+ * JSON report can leak a credential. Finding paths are normalised against the scan
10
+ * root by the orchestrator, so no consumer can receive an absolute path either.
11
+ */
12
+
13
+ import path from 'path';
14
+
15
+ const SCHEMA_VERSION = 3;
16
+
17
+ function redactFinding(f) {
18
+ if (!f || typeof f !== 'object') return f;
19
+ const isSecret = f.category === 'secrets' || f.category === 'secret'
20
+ || /secret|api[_-]?key|token|password|credential/i.test(String(f.rule || ''));
21
+ const out = { ...f };
22
+ if (f.matched) {
23
+ out.matched = isSecret
24
+ ? `${String(f.matched).slice(0, 3)}***`
25
+ : String(f.matched).slice(0, 160);
26
+ }
27
+ if (out.file) {
28
+ // Paths arrive already normalised against the scan root by the orchestrator,
29
+ // so this only has to unify separators. The three strippers that used to live
30
+ // here were wrong for everyone but this repository:
31
+ // `^[a-zA-Z]:\/+` turned `C:\Users\alice\.cursor\mcp.json` into
32
+ // `Users/alice/.cursor/mcp.json` — leaking the username and
33
+ // naming a repo-relative file that does not exist;
34
+ // the two `/Praxis/` rules were dogfooding hacks that silently truncated any
35
+ // real path merely containing a directory called `Praxis`.
36
+ // See cli/core/paths.js for the replacement.
37
+ out.file = String(out.file).split(path.sep).join('/').replace(/\\/g, '/');
38
+ }
39
+ return out;
40
+ }
41
+
42
+ export default function json(report, options = {}) {
43
+ const { pretty = true } = options;
44
+ const enriched = {
45
+ schemaVersion: SCHEMA_VERSION,
46
+ ...report,
47
+ };
48
+ if (Array.isArray(enriched.findings)) {
49
+ enriched.findings = enriched.findings.map(redactFinding);
50
+ }
51
+ return pretty
52
+ ? JSON.stringify(enriched, null, 2)
53
+ : JSON.stringify(enriched);
54
+ }
55
+
56
+ export { redactFinding, SCHEMA_VERSION };
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * SARIF v2.1.0 output formatter.
3
3
  *
4
- * The single SARIF serializer for Praxis (P-IMP-062). Every emitting path must go
4
+ * The single SARIF serializer for Praxis. Every emitting path must go
5
5
  * through here: `scan --sarif`, `scan ci --sarif`, `audit --sarif`, `redteam --sarif`,
6
6
  * `scan standard --sarif` and `--format sarif`. Four commands used to carry private
7
7
  * copies of this logic, which meant fixes applied here reached almost nobody — most
@@ -84,7 +84,7 @@ function renderSARIFDocument(report, options = {}) {
84
84
  toolName = 'praxis',
85
85
  // Defaults to the real package version. This used to fall back to a hardcoded
86
86
  // '1.0.0', so any caller that forgot to pass `toolVersion` reported a confidently
87
- // wrong driver version in Code Scanning (P-IMP-065).
87
+ // wrong driver version in Code Scanning.
88
88
  toolVersion = report.version || toolVersion_(),
89
89
  informationUri = 'https://github.com/Ganron007/Praxis',
90
90
  rootPath = null,
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Honest, publishable paths for findings.
3
+ *
4
+ * A finding is not always about a file inside the scanned tree.
5
+ * `MCP_SHADOW_CONFIG` reports the developer's own `~/.cursor/mcp.json`, because a
6
+ * shadow MCP server configured outside version control is precisely the finding
7
+ * worth reporting. That file sits above the scan root, and neither obvious way of
8
+ * rendering it is acceptable:
9
+ *
10
+ * path.relative(root, file) -> '../../../Users/alice/.cursor/mcp.json'
11
+ * leaks the username and the directory depth into
12
+ * a report that gets pasted into a CI comment.
13
+ * strip the drive letter -> 'Users/alice/.cursor/mcp.json'
14
+ * does not exist, and falsely reads as
15
+ * repo-relative, so the finding is unlocatable.
16
+ *
17
+ * The only honest rendering of a path above the root is `~/…`. It identifies the
18
+ * file, leaks neither the username nor the directory layout, and — unlike either
19
+ * alternative — is identical on every machine, so a self-scan's findings do not
20
+ * change identity when a colleague runs it.
21
+ *
22
+ * Applied once by the orchestrator, before findings reach any renderer, because
23
+ * the terminal table, the HTML and JSON reports, CI annotations and SARIF all read
24
+ * the same `finding.file`. `fix` and `remediate` build their own absolute paths and
25
+ * never read this field, so normalising it cannot redirect a write.
26
+ */
27
+
28
+ import os from 'os';
29
+ import path from 'path';
30
+
31
+ const toSlash = (p) => String(p).split(path.sep).join('/').replace(/\\/g, '/');
32
+
33
+ // `path.isAbsolute` is platform-specific: on POSIX it reports false for
34
+ // `C:/work/src/a.js`, because that is a legal relative filename there. Findings
35
+ // normally come from the host's own glob, but a path read back from a report or
36
+ // a cache can carry the other platform's shape, and treating it as relative would
37
+ // print a drive letter straight into the output. Same guard sarif.js uses.
38
+ const isAbsoluteLike = (s) => path.isAbsolute(s) || /^[a-zA-Z]:[\\/]/.test(s);
39
+
40
+ /**
41
+ * Render a finding's file for display.
42
+ *
43
+ * @param {string} file Absolute or already-relative path.
44
+ * @param {string} [root] Absolute scan root, when known.
45
+ * @returns {string} Root-relative when inside `root`, `~/…` when inside the home
46
+ * directory, otherwise the bare filename — never an absolute path.
47
+ */
48
+ export function displayPath(file, root) {
49
+ if (file === undefined || file === null || file === '') return file;
50
+
51
+ const s = String(file);
52
+ // Already display-shaped. Passing a relative path back through
53
+ // `path.relative(root, ...)` would resolve it against the cwd, escape the root
54
+ // and collapse it to a bare filename — the trap that made Code Scanning alerts
55
+ // unlocatable.
56
+ if (!isAbsoluteLike(s)) return toSlash(s);
57
+
58
+ if (root) {
59
+ const rel = path.relative(root, s);
60
+ // `rel` starting with '..' means the file is outside the scan root.
61
+ if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) return toSlash(rel);
62
+ }
63
+
64
+ const home = os.homedir();
65
+ if (home && s !== home && s.startsWith(home + path.sep)) {
66
+ return '~/' + toSlash(s.slice(home.length + 1));
67
+ }
68
+
69
+ // Outside the root and outside home: identify the file without publishing the
70
+ // local directory layout.
71
+ return path.basename(s);
72
+ }
73
+
74
+ /**
75
+ * Apply {@link displayPath} to every finding that carries a path.
76
+ *
77
+ * The orchestrator calls this once, at the boundary where findings stop being
78
+ * internal state and become report output.
79
+ *
80
+ * @param {Array<object>} findings Mutated in place and returned.
81
+ * @param {string} [root] Absolute scan root.
82
+ */
83
+ export function normalizeFindingPaths(findings, root) {
84
+ if (!Array.isArray(findings)) return findings;
85
+ for (const f of findings) {
86
+ if (f && typeof f === 'object' && f.file) f.file = displayPath(f.file, root);
87
+ }
88
+ return findings;
89
+ }
90
+
91
+ export default displayPath;
@@ -36,7 +36,7 @@ export class JobQueue extends EventEmitter {
36
36
  * @returns {{ok:true, job:object} | {ok:false, error:string}}
37
37
  */
38
38
  enqueue(project) {
39
- const queued = this.jobs.length + this.pending.length;
39
+ const queued = this.running + this.pending.length;
40
40
  if (queued >= this.maxQueue) {
41
41
  return { ok: false, error: `queue is full (max ${this.maxQueue} scans)` };
42
42
  }
@@ -203,4 +203,4 @@ export async function runScanWithOrchestrator(rootPath, onProgress) {
203
203
  standardsSummary,
204
204
  scannedAt: new Date().toISOString(),
205
205
  };
206
- }
206
+ }
@@ -18,9 +18,11 @@
18
18
 
19
19
  import http from 'http';
20
20
  import { URL } from 'url';
21
+ import { isIP } from 'net';
21
22
  import { listProjects, addProject, getProject, removeProject } from './projects.js';
22
23
  import { JobQueue, runScanWithOrchestrator } from './jobs.js';
23
24
  import { renderFrontend, AGENT_ROSTER } from './ui.js';
25
+ import jsonReport from '../output/json.js';
24
26
 
25
27
  const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']); // praxis-ignore SSRF_INTERNAL_IP — loopback allow-list: binding only to loopback IS the mitigation
26
28
  const CLIENT_HEADER = 'x-praxis-client';
@@ -28,7 +30,8 @@ const CLIENT_VALUE = 'praxis-web';
28
30
  const MAX_BODY_BYTES = 64 * 1024;
29
31
 
30
32
  export function isLoopback(host) {
31
- return LOOPBACK.has(host) || String(host).startsWith('127.');
33
+ const normalized = String(host).replace(/^\[|\]$/g, '');
34
+ return LOOPBACK.has(normalized) || (isIP(normalized) === 4 && normalized.startsWith('127.'));
32
35
  }
33
36
 
34
37
  /** Decides the effective bind, refusing an unsafe remote bind (threat T4). */
@@ -136,14 +139,14 @@ function guardMutation(req) {
136
139
  }
137
140
  const origin = req.headers.origin;
138
141
  if (origin) {
139
- let host;
142
+ let originURL;
140
143
  try {
141
- host = new URL(origin).hostname;
144
+ originURL = new URL(origin);
142
145
  } catch {
143
146
  return { ok: false, error: 'malformed Origin' };
144
147
  }
145
- // Same-origin or loopback origin only: blocks DNS rebinding.
146
- if (!isLoopback(host) && host !== 'localhost') {
148
+ // A different loopback port is still a different browser origin.
149
+ if (!['http:', 'https:'].includes(originURL.protocol) || originURL.host !== req.headers.host) {
147
150
  return { ok: false, error: 'cross-origin request refused' };
148
151
  }
149
152
  }
@@ -211,6 +214,13 @@ export function createServer({ host: _host = '127.0.0.1', port: _port = 7317, to
211
214
  const route = url.pathname.replace(/\/+$/, '') || '/';
212
215
  const method = req.method || 'GET';
213
216
 
217
+ // DNS rebinding can read reports through GET as well as mutate through POST.
218
+ // Without token auth, every request must address the actual local server.
219
+ if (!token && ((!isLoopback(url.hostname) && url.hostname !== 'localhost') ||
220
+ Number(url.port || 80) !== server.address()?.port)) {
221
+ return sendJSON(res, 403, { error: 'untrusted Host header' });
222
+ }
223
+
214
224
  // Auth for remote binds.
215
225
  if (token) {
216
226
  const auth = req.headers.authorization || '';
@@ -266,7 +276,7 @@ export function createServer({ host: _host = '127.0.0.1', port: _port = 7317, to
266
276
  if (!job) return sendJSON(res, 404, { error: 'no completed scan for this project' });
267
277
  const full = jobs.get(job.id);
268
278
  if (!full?.result) return sendJSON(res, 404, { error: 'scan has no result' });
269
- return sendJSON(res, 200, { job: jobs._public(full), result: full.result });
279
+ return sendJSON(res, 200, { job: jobs._public(full), result: JSON.parse(jsonReport(full.result)) });
270
280
  }
271
281
  }
272
282
 
@@ -279,7 +289,7 @@ export function createServer({ host: _host = '127.0.0.1', port: _port = 7317, to
279
289
  const full = jobs.get(jobReportMatch[1]);
280
290
  if (!full) return sendJSON(res, 404, { error: 'unknown job' });
281
291
  if (!full.result) return sendJSON(res, 404, { error: 'scan has no result' });
282
- return sendJSON(res, 200, { job: jobs._public(full), result: full.result });
292
+ return sendJSON(res, 200, { job: jobs._public(full), result: JSON.parse(jsonReport(full.result)) });
283
293
  }
284
294
 
285
295
  const jobMatch = route.match(/^\/api\/jobs\/([\w-]+)$/);
@@ -313,6 +323,7 @@ export function createServer({ host: _host = '127.0.0.1', port: _port = 7317, to
313
323
  });
314
324
  const send = j => res.write(`data: ${JSON.stringify(j)}\n\n`);
315
325
  send(jobs._public(job));
326
+ if (['done', 'failed', 'cancelled'].includes(job.status)) return res.end();
316
327
  const onUpdate = j => {
317
328
  if (j.id !== job.id) return;
318
329
  send(j);
@@ -350,4 +361,4 @@ export function createServer({ host: _host = '127.0.0.1', port: _port = 7317, to
350
361
  });
351
362
 
352
363
  return { server, jobs };
353
- }
364
+ }