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,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;
|