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,312 @@
1
+ /**
2
+ * Scan Playbook
3
+ * =============
4
+ *
5
+ * Hermes-inspired repo-specific intelligence that accumulates across scans
6
+ * and gets injected into the DeepAnalyzer system prompt as project context.
7
+ *
8
+ * After each scan, the playbook is updated with:
9
+ * - Tech stack (frameworks, databases, runtimes)
10
+ * - Auth patterns detected
11
+ * - Known suppressed rules (from memory)
12
+ * - Scan statistics (score trend, most common finding categories)
13
+ * - Custom notes added by the user
14
+ *
15
+ * DeepAnalyzer reads the playbook and prepends it to every LLM call so the
16
+ * model has richer context than the generic system prompt alone — reducing
17
+ * both false positives and missed findings.
18
+ *
19
+ * PLAYBOOK FILE: .praxis/playbook.md
20
+ *
21
+ * USAGE:
22
+ * import { ScanPlaybook } from '../utils/scan-playbook.js';
23
+ *
24
+ * const playbook = new ScanPlaybook(rootPath);
25
+ *
26
+ * // After each scan — update with latest recon + score
27
+ * playbook.update(recon, scoreResult, suppressedRules);
28
+ *
29
+ * // Get the context string to inject into LLM prompts
30
+ * const context = playbook.getPromptContext();
31
+ *
32
+ * // CLI
33
+ * playbook.show() — print current playbook
34
+ * playbook.addNote(text) — add a custom note
35
+ */
36
+
37
+ import fs from 'fs';
38
+ import path from 'path';
39
+
40
+ const PLAYBOOK_DIR = '.praxis';
41
+ const PLAYBOOK_FILE = 'playbook.md';
42
+ const HISTORY_FILE = 'scan-history.json';
43
+
44
+ /** Minimum number of scans before the playbook is considered reliable */
45
+ const MIN_SCANS_FOR_PLAYBOOK = 2;
46
+
47
+ export class ScanPlaybook {
48
+ constructor(rootPath) {
49
+ this.rootPath = rootPath;
50
+ this.playbookDir = path.join(rootPath, PLAYBOOK_DIR);
51
+ this.playbookPath = path.join(this.playbookDir, PLAYBOOK_FILE);
52
+ this.historyPath = path.join(this.playbookDir, HISTORY_FILE);
53
+ }
54
+
55
+ // ===========================================================================
56
+ // UPDATE — called after every scan
57
+ // ===========================================================================
58
+
59
+ /**
60
+ * Update the playbook with the latest scan data.
61
+ *
62
+ * @param {object} recon — from ReconAgent
63
+ * @param {object} scoreResult — { score, grade, totalFindings, ... }
64
+ * @param {object[]} findings — all findings (used for category frequency)
65
+ * @param {string[]} suppressedRules — rules currently in memory
66
+ */
67
+ update(recon, scoreResult, findings = [], suppressedRules = []) {
68
+ // Ensure dir exists
69
+ if (!fs.existsSync(this.playbookDir)) {
70
+ fs.mkdirSync(this.playbookDir, { recursive: true });
71
+ }
72
+
73
+ // Update scan history
74
+ const history = this._loadHistory();
75
+ history.push({
76
+ date: new Date().toISOString(),
77
+ score: scoreResult?.score ?? null,
78
+ grade: scoreResult?.grade?.letter ?? scoreResult?.grade ?? null,
79
+ totalFindings: scoreResult?.totalFindings ?? findings.length,
80
+ });
81
+ // Keep last 50 entries
82
+ while (history.length > 50) history.shift();
83
+ this._saveHistory(history);
84
+
85
+ // Only update the playbook markdown after MIN_SCANS
86
+ if (history.length < MIN_SCANS_FOR_PLAYBOOK) return;
87
+
88
+ // Preserve user-written custom notes from existing playbook
89
+ const existingNotes = this._extractCustomNotes();
90
+
91
+ const content = this._buildPlaybook(recon, history, findings, suppressedRules, existingNotes);
92
+ fs.writeFileSync(this.playbookPath, content, 'utf-8');
93
+ }
94
+
95
+ // ===========================================================================
96
+ // READ — injected into LLM prompts
97
+ // ===========================================================================
98
+
99
+ /**
100
+ * Returns a compact context string to prepend to LLM system prompts.
101
+ * Empty string if the playbook doesn't exist yet.
102
+ */
103
+ getPromptContext() {
104
+ if (!fs.existsSync(this.playbookPath)) return '';
105
+
106
+ try {
107
+ const content = fs.readFileSync(this.playbookPath, 'utf-8');
108
+ // Extract the machine-readable section between <!-- context-start --> and <!-- context-end -->
109
+ const match = content.match(/<!-- context-start -->([\s\S]*?)<!-- context-end -->/);
110
+ if (match) return match[1].trim();
111
+
112
+ // Fallback: return first 1500 chars of playbook
113
+ return content.slice(0, 1500);
114
+ } catch {
115
+ return '';
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Whether the playbook has enough data to be useful.
121
+ */
122
+ get isReady() {
123
+ return fs.existsSync(this.playbookPath);
124
+ }
125
+
126
+ // ===========================================================================
127
+ // CLI
128
+ // ===========================================================================
129
+
130
+ show() {
131
+ if (!fs.existsSync(this.playbookPath)) {
132
+ console.log('\n No playbook yet. Run `praxis audit` at least twice to generate one.\n');
133
+ return;
134
+ }
135
+ const content = fs.readFileSync(this.playbookPath, 'utf-8');
136
+ console.log('\n' + content);
137
+ }
138
+
139
+ /**
140
+ * Append a custom user note to the playbook.
141
+ */
142
+ addNote(text) {
143
+ if (!fs.existsSync(this.playbookPath)) {
144
+ console.error(' No playbook yet — run praxis audit first.');
145
+ return;
146
+ }
147
+ const existing = fs.readFileSync(this.playbookPath, 'utf-8');
148
+ const timestamp = new Date().toISOString().slice(0, 10);
149
+ const noteSection = existing.includes('## Custom Notes')
150
+ ? existing.replace('## Custom Notes', `## Custom Notes\n\n- [${timestamp}] ${text}`)
151
+ : existing + `\n\n## Custom Notes\n\n- [${timestamp}] ${text}\n`;
152
+ fs.writeFileSync(this.playbookPath, noteSection, 'utf-8');
153
+ console.log(' Note added to playbook.');
154
+ }
155
+
156
+ // ===========================================================================
157
+ // INTERNALS
158
+ // ===========================================================================
159
+
160
+ _loadHistory() {
161
+ try {
162
+ if (fs.existsSync(this.historyPath)) {
163
+ return JSON.parse(fs.readFileSync(this.historyPath, 'utf-8'));
164
+ }
165
+ } catch { /* start fresh */ }
166
+ return [];
167
+ }
168
+
169
+ _saveHistory(history) {
170
+ try {
171
+ fs.writeFileSync(this.historyPath, JSON.stringify(history, null, 2), 'utf-8');
172
+ } catch { /* non-fatal */ }
173
+ }
174
+
175
+ _extractCustomNotes() {
176
+ if (!fs.existsSync(this.playbookPath)) return '';
177
+ try {
178
+ const content = fs.readFileSync(this.playbookPath, 'utf-8');
179
+ const match = content.match(/## Custom Notes([\s\S]*)$/);
180
+ return match ? match[0] : '';
181
+ } catch { return ''; }
182
+ }
183
+
184
+ _buildPlaybook(recon, history, findings, suppressedRules, customNotes) {
185
+ const repoName = path.basename(this.rootPath);
186
+ const lastScore = history.at(-1)?.score ?? '?';
187
+ const lastGrade = history.at(-1)?.grade ?? '?';
188
+ const scanCount = history.length;
189
+ const avgScore = history.length > 0
190
+ ? Math.round(history.reduce((s, h) => s + (h.score ?? 0), 0) / history.length)
191
+ : 0;
192
+
193
+ // Score trend
194
+ const trend = history.length >= 2
195
+ ? (history.at(-1).score ?? 0) - (history.at(-2).score ?? 0)
196
+ : 0;
197
+ const trendStr = trend > 0 ? `↑ +${trend}` : trend < 0 ? `↓ ${trend}` : '→ stable';
198
+
199
+ // Category frequency
200
+ const catCounts = {};
201
+ for (const f of findings) {
202
+ catCounts[f.category || 'other'] = (catCounts[f.category || 'other'] || 0) + 1;
203
+ }
204
+ const topCategories = Object.entries(catCounts)
205
+ .sort((a, b) => b[1] - a[1])
206
+ .slice(0, 5)
207
+ .map(([cat, count]) => `${cat} (${count})`)
208
+ .join(', ') || 'none';
209
+
210
+ // Tech stack
211
+ const frameworks = recon?.frameworks?.length ? recon.frameworks.join(', ') : 'unknown';
212
+ const databases = recon?.databases?.length ? recon.databases.join(', ') : 'none detected';
213
+ const authPat = recon?.authPatterns?.length ? recon.authPatterns.join(', ') : 'none detected';
214
+ const runtime = recon?.runtime || 'unknown';
215
+ const packageManager = recon?.packageManager || 'unknown';
216
+ const language = recon?.language || recon?.primaryLanguage || 'unknown';
217
+
218
+ const suppressedSection = suppressedRules.length > 0
219
+ ? suppressedRules.map(r => `- ${r}`).join('\n')
220
+ : '- none';
221
+
222
+ const historyTable = history.slice(-10).map(h =>
223
+ `| ${h.date.slice(0, 10)} | ${h.score ?? '?'}/100 | ${h.grade ?? '?'} | ${h.totalFindings ?? '?'} |`
224
+ ).join('\n');
225
+
226
+ return `# Praxis Playbook — ${repoName}
227
+
228
+ > Auto-generated after ${scanCount} scan(s). Do not edit the section between the
229
+ > \`<!-- context-start -->\` and \`<!-- context-end -->\` markers — it is overwritten on each scan.
230
+ > Add custom notes at the bottom under **Custom Notes**.
231
+
232
+ <!-- context-start -->
233
+ REPO: ${repoName}
234
+ LANGUAGE: ${language}
235
+ RUNTIME: ${runtime}
236
+ PACKAGE_MANAGER: ${packageManager}
237
+ FRAMEWORKS: ${frameworks}
238
+ DATABASES: ${databases}
239
+ AUTH_PATTERNS: ${authPat}
240
+ LAST_SCORE: ${lastScore}/100 (${lastGrade})
241
+ TREND: ${trendStr}
242
+ AVG_SCORE: ${avgScore}/100 over ${scanCount} scans
243
+ TOP_FINDING_CATEGORIES: ${topCategories}
244
+ SUPPRESSED_RULES: ${suppressedRules.join(', ') || 'none'}
245
+ SCANS_COMPLETED: ${scanCount}
246
+ <!-- context-end -->
247
+
248
+ ## Security Profile
249
+
250
+ | Property | Value |
251
+ |----------|-------|
252
+ | Language | ${language} |
253
+ | Runtime | ${runtime} |
254
+ | Frameworks | ${frameworks} |
255
+ | Databases | ${databases} |
256
+ | Auth patterns | ${authPat} |
257
+
258
+ ## Score History (last 10 scans)
259
+
260
+ | Date | Score | Grade | Findings |
261
+ |------|-------|-------|----------|
262
+ ${historyTable}
263
+
264
+ **Current:** ${lastScore}/100 ${lastGrade} (${trendStr})
265
+
266
+ ## Known Suppressions
267
+
268
+ The following rules are currently suppressed in \`.praxis/memory.json\`:
269
+
270
+ ${suppressedSection}
271
+
272
+ ## Top Finding Categories
273
+
274
+ ${topCategories}
275
+
276
+ ${customNotes || '## Custom Notes\n\n_Add project-specific context here with: `praxis playbook add-note "..."`_\n'}
277
+ `;
278
+ }
279
+ }
280
+
281
+ // =============================================================================
282
+ // CLI COMMAND (praxis playbook)
283
+ // =============================================================================
284
+
285
+ import chalk from 'chalk';
286
+
287
+ export async function playbookCommand(subcommand, args = [], options = {}) {
288
+ const rootPath = path.resolve(options.path || '.');
289
+ const playbook = new ScanPlaybook(rootPath);
290
+
291
+ switch (subcommand) {
292
+ case 'show':
293
+ case undefined:
294
+ playbook.show();
295
+ break;
296
+
297
+ case 'add-note': {
298
+ const text = args.join(' ').trim();
299
+ if (!text) {
300
+ console.error(' Usage: praxis playbook add-note "your note here"');
301
+ process.exit(1);
302
+ }
303
+ playbook.addNote(text);
304
+ break;
305
+ }
306
+
307
+ default:
308
+ console.error(` Unknown playbook subcommand: ${subcommand}`);
309
+ console.log(' Usage: praxis playbook [show|add-note "..."]');
310
+ process.exit(1);
311
+ }
312
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Score history — read the scan-over-time series behind the trend section.
3
+ * ============================================================================
4
+ *
5
+ * `.praxis/history.json` is appended once per scan with `{timestamp, score, grade,
6
+ * totalFindings, totalDepVulns, categoryScores}`.
7
+ *
8
+ * The discipline here is about *not overstating* the data (P-IMP-055):
9
+ * - with no prior scans the project is at its **baseline** — the report must say so
10
+ * rather than draw an empty graph, which reads as "flat, no change"
11
+ * - a two-point series is **two measurements, not a trend**, and is labelled as such
12
+ * - the sample size and the time span are always shown next to the chart, so nobody
13
+ * reads three scans over four minutes as a security trajectory
14
+ *
15
+ * Best-effort and never throws: a corrupt history must not break a report, and it must
16
+ * never be silently replaced with fabricated points.
17
+ */
18
+
19
+ import fs from 'fs';
20
+ import path from 'path';
21
+
22
+ const HISTORY_FILE = path.join('.praxis', 'history.json');
23
+
24
+ /**
25
+ * Reads the score history for a project.
26
+ * @returns {{points: object[], unreadable: number, error: string|null}}
27
+ */
28
+ export function readScoreHistory(rootPath = process.cwd()) {
29
+ const root = typeof rootPath === 'string' && rootPath.length > 0 ? rootPath : process.cwd();
30
+ let raw;
31
+ try {
32
+ raw = fs.readFileSync(path.join(root, HISTORY_FILE), 'utf8');
33
+ } catch (err) {
34
+ return { points: [], unreadable: 0, error: err.code === 'ENOENT' ? null : err.message };
35
+ }
36
+
37
+ let parsed;
38
+ try {
39
+ parsed = JSON.parse(raw);
40
+ } catch (err) {
41
+ return { points: [], unreadable: 0, error: `history.json is not valid JSON: ${err.message}` };
42
+ }
43
+
44
+ if (!Array.isArray(parsed)) {
45
+ return { points: [], unreadable: 0, error: 'history.json is not an array of scan entries' };
46
+ }
47
+
48
+ const points = [];
49
+ let unreadable = 0;
50
+ for (const entry of parsed) {
51
+ if (entry && typeof entry === 'object' && typeof entry.score === 'number') {
52
+ points.push({
53
+ timestamp: entry.timestamp || null,
54
+ score: entry.score,
55
+ grade: entry.grade ?? null,
56
+ totalFindings: typeof entry.totalFindings === 'number' ? entry.totalFindings : null,
57
+ });
58
+ } else {
59
+ unreadable++;
60
+ }
61
+ }
62
+
63
+ // Oldest → newest, so callers can treat the tail as "latest".
64
+ points.sort((a, b) => String(a.timestamp ?? '').localeCompare(String(b.timestamp ?? '')));
65
+ return { points, unreadable, error: null };
66
+ }
67
+
68
+ /**
69
+ * Summarises a series for display, and states plainly how much data there is.
70
+ *
71
+ * @param {object[]} points History points, oldest first (from readScoreHistory).
72
+ * @param {object} [current] `{score, grade}` for the scan being reported, so the
73
+ * live score appears on the chart even before it is
74
+ * appended to history.
75
+ */
76
+ export function summarizeHistory(points = [], current = null) {
77
+ // Coerce first: spreading null/undefined throws, which would break the
78
+ // "never throws" contract this module advertises.
79
+ const series = Array.isArray(points) ? [...points] : [];
80
+
81
+ // Append the in-flight scan if history doesn't already end with it. Without this the
82
+ // chart would show the *previous* score as the latest point and quietly mislead.
83
+ const last = series[series.length - 1];
84
+ const currentAlreadyPresent =
85
+ current && typeof current.score === 'number' && last && last.score === current.score;
86
+ if (current && typeof current.score === 'number' && !currentAlreadyPresent) {
87
+ series.push({ timestamp: null, score: current.score, grade: current.grade ?? null, totalFindings: null, isCurrent: true });
88
+ }
89
+
90
+ const scores = series.map(p => p.score);
91
+ const latest = scores[scores.length - 1] ?? null;
92
+ const previous = scores.length >= 2 ? scores[scores.length - 2] : null;
93
+
94
+ // How many *measurements* exist, and how many are prior runs (excluding the live one).
95
+ const measurementCount = scores.length;
96
+ const priorCount = Math.max(0, measurementCount - (current && !currentAlreadyPresent ? 1 : 0));
97
+
98
+ let spanDays = null;
99
+ const dated = series.filter(p => p.timestamp);
100
+ if (dated.length >= 2) {
101
+ const t0 = Date.parse(dated[0].timestamp);
102
+ const tN = Date.parse(dated[dated.length - 1].timestamp);
103
+ if (Number.isFinite(t0) && Number.isFinite(tN)) spanDays = Math.round(((tN - t0) / 86400000) * 10) / 10;
104
+ }
105
+
106
+ return {
107
+ series,
108
+ measurementCount,
109
+ priorCount,
110
+ latest,
111
+ previous,
112
+ delta: latest !== null && previous !== null ? Math.round((latest - previous) * 10) / 10 : null,
113
+ best: scores.length ? Math.max(...scores) : null,
114
+ worst: scores.length ? Math.min(...scores) : null,
115
+ isBaseline: measurementCount <= 1 && !current,
116
+ needsMoreData: measurementCount >= 1 && measurementCount < 3,
117
+ spanDays,
118
+ };
119
+ }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Secrets Verifier
3
+ * =================
4
+ *
5
+ * Checks if leaked secrets are still active by probing provider APIs.
6
+ * Only makes safe, read-only API calls (e.g., account info endpoints).
7
+ *
8
+ * USAGE:
9
+ * const verifier = new SecretsVerifier();
10
+ * const results = await verifier.verify(findings);
11
+ */
12
+
13
+ // =============================================================================
14
+ // PROVIDER PROBES
15
+ // =============================================================================
16
+
17
+ /**
18
+ * Each probe defines how to test if a specific type of key is active.
19
+ * All probes use read-only GET endpoints — no side effects.
20
+ */
21
+ const PROBES = {
22
+ // GitHub tokens
23
+ GITHUB_TOKEN: {
24
+ label: 'GitHub',
25
+ test: async (token) => {
26
+ const res = await safeFetch('https://api.github.com/user', {
27
+ headers: { Authorization: `token ${token}`, 'User-Agent': 'praxis-verifier' },
28
+ });
29
+ if (res.status === 200) {
30
+ const data = await res.json();
31
+ return { active: true, info: `Authenticated as: ${data.login}` };
32
+ }
33
+ return { active: false };
34
+ },
35
+ },
36
+ GITHUB_PAT: { label: 'GitHub PAT', test: (t) => PROBES.GITHUB_TOKEN.test(t) },
37
+
38
+ // OpenAI
39
+ OPENAI_API_KEY: {
40
+ label: 'OpenAI',
41
+ test: async (token) => {
42
+ const res = await safeFetch('https://api.openai.com/v1/models', {
43
+ headers: { Authorization: `Bearer ${token}` },
44
+ });
45
+ return { active: res.status === 200 };
46
+ },
47
+ },
48
+
49
+ // Anthropic
50
+ ANTHROPIC_API_KEY: {
51
+ label: 'Anthropic',
52
+ test: async (token) => {
53
+ const res = await safeFetch('https://api.anthropic.com/v1/messages', {
54
+ method: 'POST',
55
+ headers: {
56
+ 'x-api-key': token,
57
+ 'anthropic-version': '2023-06-01',
58
+ 'content-type': 'application/json',
59
+ },
60
+ // Send minimal invalid request — 400 means key is valid, 401 means invalid
61
+ body: JSON.stringify({ model: 'x', max_tokens: 1, messages: [] }),
62
+ });
63
+ // 400 = valid key, bad request; 401 = invalid key
64
+ return { active: res.status !== 401 && res.status !== 403 };
65
+ },
66
+ },
67
+
68
+ // Stripe
69
+ STRIPE_LIVE_KEY: {
70
+ label: 'Stripe',
71
+ test: async (token) => {
72
+ const res = await safeFetch('https://api.stripe.com/v1/balance', {
73
+ headers: { Authorization: `Bearer ${token}` },
74
+ });
75
+ return { active: res.status === 200 };
76
+ },
77
+ },
78
+ STRIPE_SECRET_KEY: { label: 'Stripe', test: (t) => PROBES.STRIPE_LIVE_KEY.test(t) },
79
+
80
+ // AWS
81
+ AWS_ACCESS_KEY: {
82
+ label: 'AWS',
83
+ test: async (token) => {
84
+ // AWS keys need both access key and secret — we can only flag that the format is valid
85
+ return { active: null, info: 'Cannot verify AWS keys without secret key pair' };
86
+ },
87
+ },
88
+
89
+ // Slack
90
+ SLACK_TOKEN: {
91
+ label: 'Slack',
92
+ test: async (token) => {
93
+ const res = await safeFetch(`https://slack.com/api/auth.test?token=${encodeURIComponent(token)}`);
94
+ if (res.status === 200) {
95
+ const data = await res.json();
96
+ return { active: data.ok === true, info: data.ok ? `Team: ${data.team}` : undefined };
97
+ }
98
+ return { active: false };
99
+ },
100
+ },
101
+ SLACK_WEBHOOK: {
102
+ label: 'Slack Webhook',
103
+ test: async () => {
104
+ // Don't probe webhooks — they'd send a message
105
+ return { active: null, info: 'Webhook verification skipped (would send message)' };
106
+ },
107
+ },
108
+
109
+ // Sendgrid
110
+ SENDGRID_API_KEY: {
111
+ label: 'SendGrid',
112
+ test: async (token) => {
113
+ const res = await safeFetch('https://api.sendgrid.com/v3/user/profile', {
114
+ headers: { Authorization: `Bearer ${token}` },
115
+ });
116
+ return { active: res.status === 200 };
117
+ },
118
+ },
119
+
120
+ // Twilio
121
+ TWILIO_AUTH_TOKEN: {
122
+ label: 'Twilio',
123
+ test: async () => {
124
+ return { active: null, info: 'Twilio requires Account SID + Auth Token pair' };
125
+ },
126
+ },
127
+
128
+ // NPM
129
+ NPM_TOKEN: {
130
+ label: 'npm',
131
+ test: async (token) => {
132
+ const res = await safeFetch('https://registry.npmjs.org/-/whoami', {
133
+ headers: { Authorization: `Bearer ${token}` },
134
+ });
135
+ if (res.status === 200) {
136
+ const data = await res.json();
137
+ return { active: true, info: `Authenticated as: ${data.username}` };
138
+ }
139
+ return { active: false };
140
+ },
141
+ },
142
+ };
143
+
144
+ // =============================================================================
145
+ // SAFE FETCH
146
+ // =============================================================================
147
+
148
+ async function safeFetch(url, options = {}) {
149
+ const controller = new AbortController();
150
+ const timeout = setTimeout(() => controller.abort(), 5000);
151
+
152
+ try {
153
+ return await fetch(url, { ...options, signal: controller.signal }); // praxis-ignore SSRF_USER_URL_FETCH — provider API endpoint for liveness check
154
+ } catch {
155
+ return { status: 0, json: async () => ({}), text: async () => '' };
156
+ } finally {
157
+ clearTimeout(timeout);
158
+ }
159
+ }
160
+
161
+ // =============================================================================
162
+ // SECRETS VERIFIER
163
+ // =============================================================================
164
+
165
+ export class SecretsVerifier {
166
+ /**
167
+ * Verify an array of secret findings.
168
+ * Only probes findings that have a matching provider probe.
169
+ *
170
+ * @param {object[]} findings — Secret findings with rule and matched fields
171
+ * @returns {Promise<object[]>} — Findings with verifyResult attached
172
+ */
173
+ async verify(findings) {
174
+ const secretFindings = findings.filter(
175
+ f => f.category === 'secrets' || f.category === 'secret'
176
+ );
177
+
178
+ const results = [];
179
+
180
+ for (const finding of secretFindings) {
181
+ const probe = this._findProbe(finding.rule);
182
+ if (!probe) {
183
+ results.push({ finding, result: { active: null, info: 'No probe available' } });
184
+ continue;
185
+ }
186
+
187
+ // Extract the actual secret value from the match
188
+ const secret = this._extractSecret(finding.matched);
189
+ if (!secret) {
190
+ results.push({ finding, result: { active: null, info: 'Could not extract key value' } });
191
+ continue;
192
+ }
193
+
194
+ try {
195
+ const result = await probe.test(secret);
196
+ finding.verifyResult = {
197
+ active: result.active,
198
+ provider: probe.label,
199
+ info: result.info || (result.active ? 'Key is ACTIVE — rotate immediately' : 'Key is inactive or revoked'),
200
+ };
201
+ results.push({ finding, result: finding.verifyResult });
202
+ } catch {
203
+ finding.verifyResult = { active: null, provider: probe.label, info: 'Verification failed' };
204
+ results.push({ finding, result: finding.verifyResult });
205
+ }
206
+ }
207
+
208
+ return results;
209
+ }
210
+
211
+ /**
212
+ * Find the probe for a given rule name.
213
+ */
214
+ _findProbe(rule) {
215
+ // Direct match
216
+ if (PROBES[rule]) return PROBES[rule];
217
+
218
+ // Partial match (rule may have extra suffixes)
219
+ for (const [key, probe] of Object.entries(PROBES)) {
220
+ if (rule.includes(key) || key.includes(rule)) return probe;
221
+ }
222
+ return null;
223
+ }
224
+
225
+ /**
226
+ * Extract the secret value from a regex match.
227
+ * Matches typically look like: API_KEY="sk-1234..." or token: 'ghp_...'
228
+ */
229
+ _extractSecret(matched) {
230
+ if (!matched) return null;
231
+
232
+ // Try to extract quoted value
233
+ const quoted = matched.match(/['"]([^'"]{8,})['"]$/);
234
+ if (quoted) return quoted[1];
235
+
236
+ // Try to extract after = or :
237
+ const assigned = matched.match(/[=:]\s*['"]?([a-zA-Z0-9_\-./+]{8,})['"]?$/);
238
+ if (assigned) return assigned[1];
239
+
240
+ // If the match itself looks like a token, use it
241
+ if (/^[a-zA-Z0-9_\-]{20,}$/.test(matched)) return matched;
242
+
243
+ return null;
244
+ }
245
+ }
246
+
247
+ export default SecretsVerifier;