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.
- package/assets/praxis-architecture.svg +304 -0
- package/assets/praxis-logo.svg +38 -0
- 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 +10 -10
- package/cli/agents/index.js +2 -2
- package/cli/agents/injection-tester.js +8 -1
- package/cli/agents/mcp-security-agent.js +600 -594
- package/cli/agents/memory-poisoning-agent.js +1 -1
- package/cli/agents/model-file-scanner.js +1 -1
- package/cli/agents/orchestrator.js +375 -355
- 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 +1232 -1216
- package/cli/commands/baseline.js +3 -2
- package/cli/commands/benchmark.js +1 -1
- package/cli/commands/ci.js +50 -25
- package/cli/commands/deps.js +11 -5
- package/cli/commands/diff.js +2 -1
- package/cli/commands/env-audit.js +1 -1
- package/cli/commands/fix.js +1 -1
- package/cli/commands/legal.js +2 -1
- package/cli/commands/mcp.js +3 -2
- package/cli/commands/openclaw.js +3 -6
- package/cli/commands/red-team.js +351 -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-standard.js +3 -6
- 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 +3 -2
- package/cli/commands/watch.js +6 -5
- 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/json.js +56 -48
- package/cli/core/output/sarif.js +2 -2
- package/cli/core/paths.js +91 -0
- 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/cache-manager.js +2 -1
- 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 +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
|
|
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, '<')
|
|
62
|
-
.replace(/>/g, '>')
|
|
63
|
-
.replace(/"/g, '"')
|
|
64
|
-
.replace(/'/g, ''');
|
|
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, '&')
|
|
61
|
+
.replace(/</g, '<')
|
|
62
|
+
.replace(/>/g, '>')
|
|
63
|
+
.replace(/"/g, '"')
|
|
64
|
+
.replace(/'/g, ''');
|
|
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
|
+
}
|
package/cli/core/output/json.js
CHANGED
|
@@ -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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
:
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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 };
|
package/cli/core/output/sarif.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SARIF v2.1.0 output formatter.
|
|
3
3
|
*
|
|
4
|
-
* The single SARIF serializer for Praxis
|
|
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
|
|
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;
|
package/cli/core/web/jobs.js
CHANGED
|
@@ -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.
|
|
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
|
+
}
|
package/cli/core/web/server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
142
|
+
let originURL;
|
|
140
143
|
try {
|
|
141
|
-
|
|
144
|
+
originURL = new URL(origin);
|
|
142
145
|
} catch {
|
|
143
146
|
return { ok: false, error: 'malformed Origin' };
|
|
144
147
|
}
|
|
145
|
-
//
|
|
146
|
-
if (!
|
|
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
|
+
}
|