praxis-sec 1.0.0

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 (183) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +170 -0
  3. package/ai-defense/cost-protection.md +292 -0
  4. package/ai-defense/llm-security-checklist.md +324 -0
  5. package/ai-defense/prompt-injection-patterns.js +283 -0
  6. package/ai-defense/system-prompt-armor.md +327 -0
  7. package/checklists/launch-day.md +168 -0
  8. package/cli/agents/abom-generator.js +225 -0
  9. package/cli/agents/agent-attestation-agent.js +318 -0
  10. package/cli/agents/agent-config-scanner.js +787 -0
  11. package/cli/agents/agent-telemetry-agent.js +415 -0
  12. package/cli/agents/agentic-security-agent.js +296 -0
  13. package/cli/agents/agentic-supply-chain-agent.js +463 -0
  14. package/cli/agents/ai-infra-inventory-agent.js +449 -0
  15. package/cli/agents/api-fuzzer.js +345 -0
  16. package/cli/agents/auth-bypass-agent.js +348 -0
  17. package/cli/agents/base-agent.js +280 -0
  18. package/cli/agents/cicd-scanner.js +300 -0
  19. package/cli/agents/config-auditor.js +757 -0
  20. package/cli/agents/deep-analyzer.js +776 -0
  21. package/cli/agents/endpoint-agent-abuse-agent.js +404 -0
  22. package/cli/agents/exception-handler-agent.js +187 -0
  23. package/cli/agents/git-history-scanner.js +169 -0
  24. package/cli/agents/governance-audits.js +138 -0
  25. package/cli/agents/hermes-security-agent.js +536 -0
  26. package/cli/agents/html-reporter.js +1125 -0
  27. package/cli/agents/index.js +147 -0
  28. package/cli/agents/injection-tester.js +502 -0
  29. package/cli/agents/legal-risk-agent.js +328 -0
  30. package/cli/agents/llm-redteam.js +199 -0
  31. package/cli/agents/managed-agent-scanner.js +333 -0
  32. package/cli/agents/mcp-security-agent.js +588 -0
  33. package/cli/agents/memory-poisoning-agent.js +305 -0
  34. package/cli/agents/mobile-scanner.js +231 -0
  35. package/cli/agents/model-file-scanner.js +259 -0
  36. package/cli/agents/orchestrator.js +355 -0
  37. package/cli/agents/pii-compliance-agent.js +301 -0
  38. package/cli/agents/policy-engine.js +229 -0
  39. package/cli/agents/prompt-injection-prober.js +224 -0
  40. package/cli/agents/rag-security-agent.js +204 -0
  41. package/cli/agents/recon-agent.js +207 -0
  42. package/cli/agents/sbom-generator.js +265 -0
  43. package/cli/agents/scoring-engine.js +273 -0
  44. package/cli/agents/ssrf-prober.js +130 -0
  45. package/cli/agents/stateful-watcher.js +238 -0
  46. package/cli/agents/supabase-rls-agent.js +154 -0
  47. package/cli/agents/supply-chain-agent.js +857 -0
  48. package/cli/agents/swarm-orchestrator.js +200 -0
  49. package/cli/agents/verifier-agent.js +303 -0
  50. package/cli/agents/vibe-coding-agent.js +250 -0
  51. package/cli/bin/praxis.js +866 -0
  52. package/cli/commands/abom.js +73 -0
  53. package/cli/commands/agent-fix.js +1245 -0
  54. package/cli/commands/audit.js +1180 -0
  55. package/cli/commands/autofix.js +383 -0
  56. package/cli/commands/baseline.js +193 -0
  57. package/cli/commands/benchmark.js +327 -0
  58. package/cli/commands/checklist.js +223 -0
  59. package/cli/commands/ci.js +403 -0
  60. package/cli/commands/deps.js +516 -0
  61. package/cli/commands/diff.js +200 -0
  62. package/cli/commands/doctor.js +195 -0
  63. package/cli/commands/env-audit.js +349 -0
  64. package/cli/commands/fix.js +218 -0
  65. package/cli/commands/guard.js +396 -0
  66. package/cli/commands/hooks.js +278 -0
  67. package/cli/commands/init.js +514 -0
  68. package/cli/commands/legal.js +158 -0
  69. package/cli/commands/live-advisories.js +241 -0
  70. package/cli/commands/mcp.js +660 -0
  71. package/cli/commands/openclaw.js +386 -0
  72. package/cli/commands/red-team.js +350 -0
  73. package/cli/commands/redteam.js +78 -0
  74. package/cli/commands/remediate.js +797 -0
  75. package/cli/commands/rotate.js +768 -0
  76. package/cli/commands/rules.js +196 -0
  77. package/cli/commands/scan-mcp.js +534 -0
  78. package/cli/commands/scan-skill.js +588 -0
  79. package/cli/commands/scan-standard.js +251 -0
  80. package/cli/commands/scan.js +524 -0
  81. package/cli/commands/score.js +449 -0
  82. package/cli/commands/shell.js +514 -0
  83. package/cli/commands/team-report.js +398 -0
  84. package/cli/commands/undo.js +161 -0
  85. package/cli/commands/update-intel.js +126 -0
  86. package/cli/commands/vibe-check.js +276 -0
  87. package/cli/commands/watch.js +757 -0
  88. package/cli/commands/web.js +63 -0
  89. package/cli/core/ast/guardrail-detector.js +141 -0
  90. package/cli/core/ast/index.js +22 -0
  91. package/cli/core/ast/parser.js +676 -0
  92. package/cli/core/ast/scope-tree.js +287 -0
  93. package/cli/core/ast/taint-tracker.js +158 -0
  94. package/cli/core/branding.js +37 -0
  95. package/cli/core/env.js +38 -0
  96. package/cli/core/errors.js +61 -0
  97. package/cli/core/fs.js +62 -0
  98. package/cli/core/output/compliance.js +90 -0
  99. package/cli/core/output/html-theme.js +158 -0
  100. package/cli/core/output/index.js +57 -0
  101. package/cli/core/output/json.js +48 -0
  102. package/cli/core/output/sarif.js +240 -0
  103. package/cli/core/version.js +67 -0
  104. package/cli/core/web/jobs.js +183 -0
  105. package/cli/core/web/projects.js +146 -0
  106. package/cli/core/web/server.js +439 -0
  107. package/cli/data/atlas-knowledge.json +5640 -0
  108. package/cli/data/eaa-catalog.json +39 -0
  109. package/cli/data/known-mcps.json +26 -0
  110. package/cli/data/probes/prompt-injection-corpus.json +271 -0
  111. package/cli/data/threat-intel.json +85 -0
  112. package/cli/data/threatpacks/latest.json +41 -0
  113. package/cli/hooks/patterns.js +313 -0
  114. package/cli/hooks/post-tool-use.js +140 -0
  115. package/cli/hooks/pre-tool-use.js +186 -0
  116. package/cli/index.js +90 -0
  117. package/cli/providers/llm-provider.js +766 -0
  118. package/cli/utils/autofix-rules.js +74 -0
  119. package/cli/utils/cache-manager.js +310 -0
  120. package/cli/utils/compliance-map.js +191 -0
  121. package/cli/utils/entropy.js +132 -0
  122. package/cli/utils/fix-ledger.js +127 -0
  123. package/cli/utils/hermes-tool-registry.js +252 -0
  124. package/cli/utils/intel/cache.js +61 -0
  125. package/cli/utils/intel/http.js +88 -0
  126. package/cli/utils/intel/index.js +235 -0
  127. package/cli/utils/intel/merge.js +229 -0
  128. package/cli/utils/intel/sources/epss.js +54 -0
  129. package/cli/utils/intel/sources/ghsa.js +81 -0
  130. package/cli/utils/intel/sources/gitguardian.js +40 -0
  131. package/cli/utils/intel/sources/gitleaks.js +101 -0
  132. package/cli/utils/intel/sources/kev.js +38 -0
  133. package/cli/utils/intel/sources/nvd.js +84 -0
  134. package/cli/utils/intel/sources/osv.js +132 -0
  135. package/cli/utils/intel/sources/phylum.js +44 -0
  136. package/cli/utils/intel/sources/snyk.js +46 -0
  137. package/cli/utils/intel/sources/socket.js +69 -0
  138. package/cli/utils/intel/sources/sonatype.js +84 -0
  139. package/cli/utils/intel/sources/threatpack.js +69 -0
  140. package/cli/utils/mcp-trust.js +60 -0
  141. package/cli/utils/output.js +251 -0
  142. package/cli/utils/patterns.js +1130 -0
  143. package/cli/utils/pdf-generator.js +94 -0
  144. package/cli/utils/plugin-loader.js +364 -0
  145. package/cli/utils/rule-import.js +228 -0
  146. package/cli/utils/rule-registry.js +426 -0
  147. package/cli/utils/scan-fingerprint.js +109 -0
  148. package/cli/utils/scan-playbook.js +312 -0
  149. package/cli/utils/score-history.js +119 -0
  150. package/cli/utils/secrets-verifier.js +247 -0
  151. package/cli/utils/security-memory.js +296 -0
  152. package/cli/utils/standards/atlas-knowledge.js +87 -0
  153. package/cli/utils/standards/index.js +127 -0
  154. package/cli/utils/standards/sources/avid.js +45 -0
  155. package/cli/utils/standards/sources/eu-ai-act.js +89 -0
  156. package/cli/utils/standards/sources/google-saif.js +39 -0
  157. package/cli/utils/standards/sources/iso-42001.js +94 -0
  158. package/cli/utils/standards/sources/mitre-atlas.js +54 -0
  159. package/cli/utils/standards/sources/nist-ai-600-1.js +45 -0
  160. package/cli/utils/standards/sources/owasp-llm.js +45 -0
  161. package/cli/utils/standards/sources/owasp-ml.js +45 -0
  162. package/cli/utils/threat-intel.js +265 -0
  163. package/configs/firebase/firestore-rules.txt +215 -0
  164. package/configs/firebase/security-checklist.md +236 -0
  165. package/configs/firebase/storage-rules.txt +206 -0
  166. package/configs/gitignore-template +258 -0
  167. package/configs/nextjs-security-headers.js +220 -0
  168. package/configs/praxisignore-template +50 -0
  169. package/configs/supabase/secure-client.ts +225 -0
  170. package/configs/supabase/security-checklist.md +278 -0
  171. package/docs/THIRD_PARTY_NOTICES.md +26 -0
  172. package/docs/THREAT_INTEL.md +292 -0
  173. package/docs/USAGE.md +1205 -0
  174. package/docs/design/WEB-UI.md +82 -0
  175. package/package.json +71 -0
  176. package/scripts/check-determinism.mjs +119 -0
  177. package/snippets/README.md +122 -0
  178. package/snippets/api-security/api-security-checklist.md +412 -0
  179. package/snippets/api-security/cors-config.ts +322 -0
  180. package/snippets/api-security/input-validation.ts +430 -0
  181. package/snippets/auth/jwt-checklist.md +322 -0
  182. package/snippets/rate-limiting/nextjs-middleware.ts +211 -0
  183. package/snippets/rate-limiting/upstash-ratelimit.ts +229 -0
@@ -0,0 +1,90 @@
1
+ import { getStandardsSummary } from '../../utils/standards/index.js';
2
+
3
+ export default function renderComplianceReport(report, options = {}) {
4
+ const findings = report.findings || [];
5
+ const summary = getStandardsSummary(findings);
6
+
7
+ // Focus on OWASP LLM and NIST AI 600-1
8
+ const owasp = summary['owasp-llm'];
9
+ const nist = summary['nist-ai-600-1'];
10
+
11
+ const lines = [];
12
+ lines.push('================================================================================');
13
+ lines.push(' PRAXIS GRC COMPLIANCE AUDIT REPORT');
14
+ lines.push('================================================================================');
15
+ lines.push(`Scanned At: ${report.scannedAt || new Date().toISOString()}`);
16
+ lines.push(`Total Findings: ${findings.length}`);
17
+ lines.push(`Security Score: ${report.score || 100}/100 (Grade: ${report.grade || 'A'})`);
18
+ lines.push('');
19
+
20
+ lines.push('--------------------------------------------------------------------------------');
21
+ lines.push(' STANDARDS COVERAGE SUMMARY');
22
+ lines.push('--------------------------------------------------------------------------------');
23
+ if (owasp) {
24
+ lines.push(` * ${owasp.title} (v${owasp.version}):`);
25
+ lines.push(` - Controls Flagged: ${owasp.flaggedControls} of ${owasp.totalControls} (${owasp.coverage})`);
26
+ }
27
+ if (nist) {
28
+ lines.push(` * ${nist.title} (v${nist.version}):`);
29
+ lines.push(` - Controls Flagged: ${nist.flaggedControls} of ${nist.totalControls} (${nist.coverage})`);
30
+ }
31
+ lines.push('');
32
+
33
+ lines.push('--------------------------------------------------------------------------------');
34
+ lines.push(' DETAILED CONTROL MAPPINGS');
35
+ lines.push('--------------------------------------------------------------------------------');
36
+
37
+ const renderDetailedStandard = (std) => {
38
+ if (!std) return;
39
+ lines.push(`### Standard: ${std.title} (v${std.version})`);
40
+ lines.push(`URL: ${std.url}`);
41
+ lines.push('');
42
+ const flagged = (std.controls || []).filter(c => c.findingCount > 0);
43
+ if (flagged.length === 0) {
44
+ lines.push(' ✔ All controls in this standard are compliant (no findings).');
45
+ lines.push('');
46
+ return;
47
+ }
48
+
49
+ for (const ctrl of flagged) {
50
+ lines.push(` [NON-COMPLIANT] Control ${ctrl.id}: ${ctrl.title}`);
51
+ if (ctrl.description) {
52
+ lines.push(` Description: ${ctrl.description}`);
53
+ }
54
+ lines.push(` Flagged Occurrences: ${ctrl.findingCount}`);
55
+ lines.push('');
56
+
57
+ // List matching findings
58
+ const matchingFindings = findings.filter(f => {
59
+ const stdMap = f.standards || {};
60
+ const ctrlIds = stdMap[std.name] || [];
61
+ return ctrlIds.includes(ctrl.id);
62
+ });
63
+
64
+ for (const f of matchingFindings) {
65
+ lines.push(` - [${f.severity.toUpperCase()}] ${f.file}${f.line ? ` (line ${f.line})` : ''}`);
66
+ lines.push(` Finding: ${f.title}`);
67
+ lines.push(` Remediation: ${f.remediation || 'Refer to Praxis guidelines'}`);
68
+ }
69
+ lines.push('');
70
+ }
71
+ };
72
+
73
+ if (owasp) renderDetailedStandard(owasp);
74
+ if (nist) renderDetailedStandard(nist);
75
+
76
+ lines.push('================================================================================');
77
+ lines.push(' GRC RECOMMENDATIONS');
78
+ lines.push('================================================================================');
79
+ if (findings.length === 0) {
80
+ lines.push(' 1. Maintain current posture by scheduling regular automated scans.');
81
+ lines.push(' 2. Enable real-time commit hooks to prevent future secret exposure.');
82
+ } else {
83
+ lines.push(' 1. Prioritize critical and high severity findings matching OWASP LLM01/LLM02.');
84
+ lines.push(' 2. Implement runtime guardrails to satisfy NIST AI 600-1 inputs/outputs mapping.');
85
+ lines.push(' 3. Remediate flagged findings before promoting code to production environments.');
86
+ }
87
+ lines.push('================================================================================');
88
+
89
+ return lines.join('\n');
90
+ }
@@ -0,0 +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
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Output formatter registry
3
+ * =========================
4
+ *
5
+ * Centralized place to register output formats so `--format <name>` is a
6
+ * lookup, not a per-command switch statement. Eliminates the 4 separate
7
+ * formatter implementations that previously lived inline in
8
+ * `cli/commands/{audit,ci,team-report,diff}.js`.
9
+ *
10
+ * Usage:
11
+ * import { render } from 'cli/core/output/index.js';
12
+ * const text = render('json', report);
13
+ * console.log(text);
14
+ *
15
+ * Adding a new format: write a renderer at `./your-format.js` that
16
+ * exports `default function(report, options): string`, then add it to
17
+ * the registry below.
18
+ */
19
+
20
+ import json from './json.js';
21
+ import sarif from './sarif.js';
22
+ import compliance from './compliance.js';
23
+
24
+ const REGISTRY = {
25
+ json,
26
+ sarif,
27
+ compliance,
28
+ };
29
+
30
+ export function listFormats() {
31
+ return Object.keys(REGISTRY);
32
+ }
33
+
34
+ export function hasFormat(name) {
35
+ return Object.prototype.hasOwnProperty.call(REGISTRY, name);
36
+ }
37
+
38
+ /**
39
+ * Render `report` into the requested format. Throws if the format is
40
+ * unknown so callers can present the available list.
41
+ */
42
+ export function render(format, report, options = {}) {
43
+ const renderer = REGISTRY[format];
44
+ if (!renderer) {
45
+ throw new Error(
46
+ `unknown format '${format}'. available: ${listFormats().join(', ')}`
47
+ );
48
+ }
49
+ return renderer(report, options);
50
+ }
51
+
52
+ /**
53
+ * Register an additional format at runtime. Used by tests and plugins.
54
+ */
55
+ export function registerFormat(name, renderer) {
56
+ REGISTRY[name] = renderer;
57
+ }
@@ -0,0 +1,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.
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 };
@@ -0,0 +1,240 @@
1
+ /**
2
+ * SARIF v2.1.0 output formatter.
3
+ *
4
+ * The single SARIF serializer for Praxis (P-IMP-062). Every emitting path must go
5
+ * through here: `scan --sarif`, `scan ci --sarif`, `audit --sarif`, `redteam --sarif`,
6
+ * `scan standard --sarif` and `--format sarif`. Four commands used to carry private
7
+ * copies of this logic, which meant fixes applied here reached almost nobody — most
8
+ * visibly `security-severity`, which the GitHub Action's `scan ci --sarif` call never
9
+ * emitted. Do not reintroduce a local serializer.
10
+ *
11
+ * Spec: https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html
12
+ */
13
+
14
+ import path from 'path';
15
+ import { toolVersion as toolVersion_ } from '../version.js';
16
+
17
+ const SARIF_VERSION = '2.1.0';
18
+
19
+ /**
20
+ * Severity → SARIF `level` + GitHub `security-severity`.
21
+ *
22
+ * These are two different axes and both are needed:
23
+ * - `level` is the coarse SARIF gate (`error` / `warning` / `note`).
24
+ * - `security-severity` is the 0.0-10.0 number GitHub Code Scanning uses to rank,
25
+ * colour and filter alerts.
26
+ *
27
+ * Emitting `level` alone collapsed critical and high into the same bucket, so every
28
+ * Code Scanning consumer saw compressed severity — the tiering the product is built on
29
+ * was invisible exactly where people look for it. Both fields come from this one table
30
+ * so they cannot drift apart.
31
+ *
32
+ * Emitted as a string because that is the form GitHub's own documentation uses.
33
+ */
34
+ const SEVERITY = {
35
+ critical: { level: 'error', securitySeverity: '9.5' },
36
+ high: { level: 'error', securitySeverity: '7.5' },
37
+ medium: { level: 'warning', securitySeverity: '5.0' },
38
+ low: { level: 'note', securitySeverity: '2.5' },
39
+ info: { level: 'note', securitySeverity: '0.0' },
40
+ };
41
+
42
+ /** Unknown or absent severity must not silently read as low-risk. */
43
+ const DEFAULT_SEVERITY = { level: 'warning', securitySeverity: '5.0' };
44
+
45
+ const forSeverity = (severity) => SEVERITY[severity] || DEFAULT_SEVERITY;
46
+
47
+ /**
48
+ * Normalizes Praxis's internal finding shape and renders SARIF.
49
+ *
50
+ * Commands hold findings in two shapes: a flat list using `rule`/`title`/`file`, and
51
+ * the per-file `[{ file, findings }]` shape the orchestrator returns. This accepts
52
+ * either, so no command has to know the SARIF field names — which is what let four
53
+ * private serializers drift apart in the first place.
54
+ *
55
+ * @param {object[]} findings
56
+ * @param {object} [options] forwarded to the serializer; pass `rootPath` or artifact
57
+ * URIs will not be repo-relative.
58
+ */
59
+ export function renderFindingsSARIF(findings, options = {}) {
60
+ const flat = Array.isArray(findings) && findings.length > 0 && findings[0] && Array.isArray(findings[0].findings)
61
+ ? findings.flatMap(({ file, findings: fileFindings }) =>
62
+ (fileFindings || []).map((f) => ({ ...f, file: f.file || file })))
63
+ : (findings || []);
64
+
65
+ return renderSARIFDocument({
66
+ findings: flat.map((f) => ({
67
+ ruleId: f.ruleId || f.rule || f.patternName || f.pattern || f.type,
68
+ title: f.title || f.patternName || f.rule,
69
+ file: f.file,
70
+ line: f.line,
71
+ column: f.column,
72
+ severity: f.severity,
73
+ description: f.description,
74
+ cwe: f.cwe,
75
+ owasp: f.owasp,
76
+ standards: f.standards,
77
+ category: f.category,
78
+ })),
79
+ }, options);
80
+ }
81
+
82
+ function renderSARIFDocument(report, options = {}) {
83
+ const {
84
+ toolName = 'praxis',
85
+ // Defaults to the real package version. This used to fall back to a hardcoded
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).
88
+ toolVersion = report.version || toolVersion_(),
89
+ informationUri = 'https://github.com/Ganron007/Praxis',
90
+ rootPath = null,
91
+ } = options;
92
+
93
+ const findings = report.findings || [];
94
+ const rules = collectRules(findings);
95
+ const relativize = makeRelativizer(rootPath);
96
+
97
+ const sarifReport = {
98
+ $schema: 'https://json.schemastore.org/sarif-2.1.0.json',
99
+ version: SARIF_VERSION,
100
+ runs: [
101
+ {
102
+ tool: {
103
+ driver: {
104
+ name: toolName,
105
+ version: toolVersion,
106
+ informationUri,
107
+ rules,
108
+ },
109
+ },
110
+ results: findings.map((f) => toResult(f, relativize)),
111
+ },
112
+ ],
113
+ };
114
+
115
+ return JSON.stringify(sarifReport, null, 2);
116
+ }
117
+
118
+ /**
119
+ * Builds the artifact-URI normaliser.
120
+ *
121
+ * SARIF is uploaded to GitHub Code Scanning, so artifact URIs must be repo-relative.
122
+ * When `rootPath` is supplied, findings are relativized against it. Without it we fall
123
+ * back to stripping a leading drive letter or POSIX root and collapsing `..` segments.
124
+ *
125
+ * This fallback used to strip a hardcoded `/Praxis/` prefix. That was written for this
126
+ * repository's own dogfooding and failed open for every real user: a Windows path
127
+ * surfaced the username and the whole directory tree, and a POSIX path passed through
128
+ * untouched — publishing the local filesystem layout into a target repository.
129
+ */
130
+ function makeRelativizer(rootPath) {
131
+ // A path that is already relative needs no relativizing. This matters: feeding
132
+ // `src/nested/deep.js` to `path.relative(root, ...)` resolves it against the cwd,
133
+ // escapes the root, and the fallback then reduces it to `deep.js` — losing the
134
+ // directory and making the alert unlocatable in Code Scanning.
135
+ const isAbsolute = (f) => path.isAbsolute(f) || /^[a-zA-Z]:[\\/]/.test(f);
136
+
137
+ if (rootPath) {
138
+ return (file) => {
139
+ const s = String(file);
140
+ if (!s) return s;
141
+ if (!isAbsolute(s)) return s.split(path.sep).join('/');
142
+
143
+ const rel = path.relative(rootPath, s);
144
+ // A file outside the root still must not leak an absolute path; fall back to the
145
+ // basename rather than emitting `../../..`.
146
+ if (!rel || rel.startsWith('..')) return path.basename(s);
147
+ return rel.split(path.sep).join('/');
148
+ };
149
+ }
150
+
151
+ return (file) => {
152
+ let s = String(file).split(path.sep).join('/');
153
+ s = s.replace(/^[a-zA-Z]:\/*/, '').replace(/^\/+/, '');
154
+
155
+ // Collapse `.`/`..` segments without escaping the repo.
156
+ const out = [];
157
+ for (const seg of s.split('/')) {
158
+ if (seg === '' || seg === '.') continue;
159
+ if (seg === '..') { out.pop(); continue; }
160
+ out.push(seg);
161
+ }
162
+ return out.join('/') || path.basename(String(file));
163
+ };
164
+ }
165
+
166
+ function collectRules(findings) {
167
+ const seen = new Map();
168
+ for (const f of findings) {
169
+ const id = f.ruleId || f.pattern || f.type || 'finding';
170
+ if (seen.has(id)) continue;
171
+ // Collect tags for the rule definition so GitHub Security tab groups
172
+ // Praxis findings by AI/LLM/MCP/supply-chain categories.
173
+ const ruleTags = ['praxis'];
174
+ if (f.category) ruleTags.push(f.category);
175
+ if (f.owasp) ruleTags.push(f.owasp);
176
+ if (f.standards) {
177
+ for (const [, ids] of Object.entries(f.standards)) {
178
+ for (const sid of ids) ruleTags.push(sid);
179
+ }
180
+ }
181
+ const sev = forSeverity(f.severity);
182
+ seen.set(id, {
183
+ id,
184
+ name: f.patternName || id,
185
+ shortDescription: { text: f.patternName || id },
186
+ fullDescription: { text: f.description || f.patternName || id },
187
+ defaultConfiguration: {
188
+ level: sev.level,
189
+ },
190
+ properties: {
191
+ tags: [...new Set(ruleTags)], // dedup
192
+ // GitHub Code Scanning ranks and filters on this numeric property.
193
+ 'security-severity': sev.securitySeverity,
194
+ },
195
+ });
196
+ }
197
+ return [...seen.values()];
198
+ }
199
+
200
+ function toResult(f, relativize) {
201
+ const uri = relativize(f.file || f.path || '');
202
+
203
+ const sev = forSeverity(f.severity);
204
+
205
+ const result = {
206
+ ruleId: f.ruleId || f.pattern || f.type || 'finding',
207
+ level: sev.level,
208
+ message: { text: f.description || f.message || f.patternName || 'finding' },
209
+ locations: [
210
+ {
211
+ physicalLocation: {
212
+ artifactLocation: { uri, uriBaseId: '%SRCROOT%' },
213
+ region: {
214
+ startLine: f.line || 1,
215
+ startColumn: f.column || 1,
216
+ },
217
+ },
218
+ },
219
+ ],
220
+ };
221
+
222
+ // Embed AI-security standard tags so SARIF consumers (GitHub Code Scanning,
223
+ // SonarQube, etc.) can filter / display alignment per finding.
224
+ const props = { 'security-severity': sev.securitySeverity };
225
+ if (f.cwe) props.cwe = f.cwe;
226
+ if (f.owasp) props.owasp = f.owasp;
227
+ if (f.standards && Object.keys(f.standards).length > 0) {
228
+ props.standards = f.standards;
229
+ const tags = [];
230
+ for (const [, ids] of Object.entries(f.standards)) {
231
+ for (const id of ids) tags.push(id);
232
+ }
233
+ if (tags.length > 0) props.tags = tags;
234
+ }
235
+ result.properties = props;
236
+
237
+ return result;
238
+ }
239
+
240
+ export default renderSARIFDocument;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Praxis's own version — the single source of truth.
3
+ * ============================================================================
4
+ *
5
+ * Six modules used to read `package.json` independently for the version, and a
6
+ * seventh (`core/output/sarif.js`) did not read it at all and fell back to a
7
+ * hardcoded `'1.0.0'`. That is six chances to drift at release time and one that
8
+ * reported a confidently wrong number in SARIF provenance.
9
+ *
10
+ * Everything imports `toolVersion()` from here instead. Two consequences worth
11
+ * knowing:
12
+ *
13
+ * - The value is cached for the process lifetime, so it is cheap to call from
14
+ * hot paths and cannot change mid-run.
15
+ * - If the read fails it returns the string `'unknown'` rather than guessing.
16
+ * A wrong version is worse than an honest one: `cache-manager` keys its cache
17
+ * on this value, so a stale guess would silently reuse stale findings.
18
+ */
19
+
20
+ import fs from 'fs';
21
+ import path from 'path';
22
+ import { fileURLToPath } from 'url';
23
+
24
+ /** Returned when package.json cannot be read. Never a plausible version string. */
25
+ export const UNKNOWN_VERSION = 'unknown';
26
+
27
+ let cached = null;
28
+
29
+ /**
30
+ * @returns {string} the Praxis version, or `'unknown'` if it cannot be determined
31
+ */
32
+ export function toolVersion() {
33
+ if (cached !== null) return cached;
34
+
35
+ try {
36
+ // cli/core/ -> repo root. Resolved from import.meta.url rather than __dirname so it
37
+ // behaves identically on Windows and POSIX.
38
+ const here = path.dirname(fileURLToPath(import.meta.url));
39
+ const pkg = JSON.parse(fs.readFileSync(path.resolve(here, '..', '..', 'package.json'), 'utf8'));
40
+ const version = typeof pkg.version === 'string' ? pkg.version.trim() : '';
41
+ cached = version || UNKNOWN_VERSION;
42
+ } catch {
43
+ cached = UNKNOWN_VERSION;
44
+ }
45
+
46
+ return cached;
47
+ }
48
+
49
+ /**
50
+ * True when `a` is a higher version than `b`. Exported because `doctor` needs the same
51
+ * comparison and previously kept its own copy.
52
+ *
53
+ * Returns false for anything unparseable, so an odd version string never produces a
54
+ * spurious "update available".
55
+ */
56
+ export function isNewerVersion(candidate, current) {
57
+ const parse = (v) => String(v ?? '').split(/[.+-]/).map((n) => parseInt(n, 10) || 0);
58
+ const a = parse(candidate);
59
+ const b = parse(current);
60
+ if (a.length === 0 || b.length === 0) return false;
61
+ const len = Math.max(a.length, b.length);
62
+ for (let i = 0; i < len; i++) {
63
+ const d = (a[i] || 0) - (b[i] || 0);
64
+ if (d !== 0) return d > 0;
65
+ }
66
+ return false;
67
+ }