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.
- package/LICENSE +21 -0
- package/README.md +170 -0
- package/ai-defense/cost-protection.md +292 -0
- package/ai-defense/llm-security-checklist.md +324 -0
- package/ai-defense/prompt-injection-patterns.js +283 -0
- package/ai-defense/system-prompt-armor.md +327 -0
- package/checklists/launch-day.md +168 -0
- package/cli/agents/abom-generator.js +225 -0
- package/cli/agents/agent-attestation-agent.js +318 -0
- package/cli/agents/agent-config-scanner.js +787 -0
- package/cli/agents/agent-telemetry-agent.js +415 -0
- package/cli/agents/agentic-security-agent.js +296 -0
- package/cli/agents/agentic-supply-chain-agent.js +463 -0
- package/cli/agents/ai-infra-inventory-agent.js +449 -0
- package/cli/agents/api-fuzzer.js +345 -0
- package/cli/agents/auth-bypass-agent.js +348 -0
- package/cli/agents/base-agent.js +280 -0
- package/cli/agents/cicd-scanner.js +300 -0
- package/cli/agents/config-auditor.js +757 -0
- package/cli/agents/deep-analyzer.js +776 -0
- package/cli/agents/endpoint-agent-abuse-agent.js +404 -0
- package/cli/agents/exception-handler-agent.js +187 -0
- package/cli/agents/git-history-scanner.js +169 -0
- package/cli/agents/governance-audits.js +138 -0
- package/cli/agents/hermes-security-agent.js +536 -0
- package/cli/agents/html-reporter.js +1125 -0
- package/cli/agents/index.js +147 -0
- package/cli/agents/injection-tester.js +502 -0
- package/cli/agents/legal-risk-agent.js +328 -0
- package/cli/agents/llm-redteam.js +199 -0
- package/cli/agents/managed-agent-scanner.js +333 -0
- package/cli/agents/mcp-security-agent.js +588 -0
- package/cli/agents/memory-poisoning-agent.js +305 -0
- package/cli/agents/mobile-scanner.js +231 -0
- package/cli/agents/model-file-scanner.js +259 -0
- package/cli/agents/orchestrator.js +355 -0
- package/cli/agents/pii-compliance-agent.js +301 -0
- package/cli/agents/policy-engine.js +229 -0
- package/cli/agents/prompt-injection-prober.js +224 -0
- package/cli/agents/rag-security-agent.js +204 -0
- package/cli/agents/recon-agent.js +207 -0
- package/cli/agents/sbom-generator.js +265 -0
- package/cli/agents/scoring-engine.js +273 -0
- package/cli/agents/ssrf-prober.js +130 -0
- package/cli/agents/stateful-watcher.js +238 -0
- package/cli/agents/supabase-rls-agent.js +154 -0
- package/cli/agents/supply-chain-agent.js +857 -0
- package/cli/agents/swarm-orchestrator.js +200 -0
- package/cli/agents/verifier-agent.js +303 -0
- package/cli/agents/vibe-coding-agent.js +250 -0
- package/cli/bin/praxis.js +866 -0
- package/cli/commands/abom.js +73 -0
- package/cli/commands/agent-fix.js +1245 -0
- package/cli/commands/audit.js +1180 -0
- package/cli/commands/autofix.js +383 -0
- package/cli/commands/baseline.js +193 -0
- package/cli/commands/benchmark.js +327 -0
- package/cli/commands/checklist.js +223 -0
- package/cli/commands/ci.js +403 -0
- package/cli/commands/deps.js +516 -0
- package/cli/commands/diff.js +200 -0
- package/cli/commands/doctor.js +195 -0
- package/cli/commands/env-audit.js +349 -0
- package/cli/commands/fix.js +218 -0
- package/cli/commands/guard.js +396 -0
- package/cli/commands/hooks.js +278 -0
- package/cli/commands/init.js +514 -0
- package/cli/commands/legal.js +158 -0
- package/cli/commands/live-advisories.js +241 -0
- package/cli/commands/mcp.js +660 -0
- package/cli/commands/openclaw.js +386 -0
- package/cli/commands/red-team.js +350 -0
- package/cli/commands/redteam.js +78 -0
- package/cli/commands/remediate.js +797 -0
- package/cli/commands/rotate.js +768 -0
- package/cli/commands/rules.js +196 -0
- package/cli/commands/scan-mcp.js +534 -0
- package/cli/commands/scan-skill.js +588 -0
- package/cli/commands/scan-standard.js +251 -0
- package/cli/commands/scan.js +524 -0
- package/cli/commands/score.js +449 -0
- package/cli/commands/shell.js +514 -0
- package/cli/commands/team-report.js +398 -0
- package/cli/commands/undo.js +161 -0
- package/cli/commands/update-intel.js +126 -0
- package/cli/commands/vibe-check.js +276 -0
- package/cli/commands/watch.js +757 -0
- package/cli/commands/web.js +63 -0
- package/cli/core/ast/guardrail-detector.js +141 -0
- package/cli/core/ast/index.js +22 -0
- package/cli/core/ast/parser.js +676 -0
- package/cli/core/ast/scope-tree.js +287 -0
- package/cli/core/ast/taint-tracker.js +158 -0
- package/cli/core/branding.js +37 -0
- package/cli/core/env.js +38 -0
- package/cli/core/errors.js +61 -0
- package/cli/core/fs.js +62 -0
- package/cli/core/output/compliance.js +90 -0
- package/cli/core/output/html-theme.js +158 -0
- package/cli/core/output/index.js +57 -0
- package/cli/core/output/json.js +48 -0
- package/cli/core/output/sarif.js +240 -0
- package/cli/core/version.js +67 -0
- package/cli/core/web/jobs.js +183 -0
- package/cli/core/web/projects.js +146 -0
- package/cli/core/web/server.js +439 -0
- package/cli/data/atlas-knowledge.json +5640 -0
- package/cli/data/eaa-catalog.json +39 -0
- package/cli/data/known-mcps.json +26 -0
- package/cli/data/probes/prompt-injection-corpus.json +271 -0
- package/cli/data/threat-intel.json +85 -0
- package/cli/data/threatpacks/latest.json +41 -0
- package/cli/hooks/patterns.js +313 -0
- package/cli/hooks/post-tool-use.js +140 -0
- package/cli/hooks/pre-tool-use.js +186 -0
- package/cli/index.js +90 -0
- package/cli/providers/llm-provider.js +766 -0
- package/cli/utils/autofix-rules.js +74 -0
- package/cli/utils/cache-manager.js +310 -0
- package/cli/utils/compliance-map.js +191 -0
- package/cli/utils/entropy.js +132 -0
- package/cli/utils/fix-ledger.js +127 -0
- package/cli/utils/hermes-tool-registry.js +252 -0
- package/cli/utils/intel/cache.js +61 -0
- package/cli/utils/intel/http.js +88 -0
- package/cli/utils/intel/index.js +235 -0
- package/cli/utils/intel/merge.js +229 -0
- package/cli/utils/intel/sources/epss.js +54 -0
- package/cli/utils/intel/sources/ghsa.js +81 -0
- package/cli/utils/intel/sources/gitguardian.js +40 -0
- package/cli/utils/intel/sources/gitleaks.js +101 -0
- package/cli/utils/intel/sources/kev.js +38 -0
- package/cli/utils/intel/sources/nvd.js +84 -0
- package/cli/utils/intel/sources/osv.js +132 -0
- package/cli/utils/intel/sources/phylum.js +44 -0
- package/cli/utils/intel/sources/snyk.js +46 -0
- package/cli/utils/intel/sources/socket.js +69 -0
- package/cli/utils/intel/sources/sonatype.js +84 -0
- package/cli/utils/intel/sources/threatpack.js +69 -0
- package/cli/utils/mcp-trust.js +60 -0
- package/cli/utils/output.js +251 -0
- package/cli/utils/patterns.js +1130 -0
- package/cli/utils/pdf-generator.js +94 -0
- package/cli/utils/plugin-loader.js +364 -0
- package/cli/utils/rule-import.js +228 -0
- package/cli/utils/rule-registry.js +426 -0
- package/cli/utils/scan-fingerprint.js +109 -0
- package/cli/utils/scan-playbook.js +312 -0
- package/cli/utils/score-history.js +119 -0
- package/cli/utils/secrets-verifier.js +247 -0
- package/cli/utils/security-memory.js +296 -0
- package/cli/utils/standards/atlas-knowledge.js +87 -0
- package/cli/utils/standards/index.js +127 -0
- package/cli/utils/standards/sources/avid.js +45 -0
- package/cli/utils/standards/sources/eu-ai-act.js +89 -0
- package/cli/utils/standards/sources/google-saif.js +39 -0
- package/cli/utils/standards/sources/iso-42001.js +94 -0
- package/cli/utils/standards/sources/mitre-atlas.js +54 -0
- package/cli/utils/standards/sources/nist-ai-600-1.js +45 -0
- package/cli/utils/standards/sources/owasp-llm.js +45 -0
- package/cli/utils/standards/sources/owasp-ml.js +45 -0
- package/cli/utils/threat-intel.js +265 -0
- package/configs/firebase/firestore-rules.txt +215 -0
- package/configs/firebase/security-checklist.md +236 -0
- package/configs/firebase/storage-rules.txt +206 -0
- package/configs/gitignore-template +258 -0
- package/configs/nextjs-security-headers.js +220 -0
- package/configs/praxisignore-template +50 -0
- package/configs/supabase/secure-client.ts +225 -0
- package/configs/supabase/security-checklist.md +278 -0
- package/docs/THIRD_PARTY_NOTICES.md +26 -0
- package/docs/THREAT_INTEL.md +292 -0
- package/docs/USAGE.md +1205 -0
- package/docs/design/WEB-UI.md +82 -0
- package/package.json +71 -0
- package/scripts/check-determinism.mjs +119 -0
- package/snippets/README.md +122 -0
- package/snippets/api-security/api-security-checklist.md +412 -0
- package/snippets/api-security/cors-config.ts +322 -0
- package/snippets/api-security/input-validation.ts +430 -0
- package/snippets/auth/jwt-checklist.md +322 -0
- package/snippets/rate-limiting/nextjs-middleware.ts +211 -0
- 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, '<')
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|