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,797 @@
1
+ /**
2
+ * Remediate Command
3
+ * =================
4
+ *
5
+ * Automatically fixes hardcoded secrets by:
6
+ * 1. Replacing them with environment variable references in source code
7
+ * 2. Writing actual values to .env (atomic write, 0o600 permissions)
8
+ * 3. Adding .env to .gitignore BEFORE writing .env
9
+ * 4. Updating .env.example with safe placeholders
10
+ *
11
+ * USAGE:
12
+ * praxis remediate . Interactive — shows diff, confirms per file
13
+ * praxis remediate . --dry-run Preview only, writes nothing
14
+ * praxis remediate . --yes Apply all without prompting (CI use)
15
+ * praxis remediate . --stage Also run git add on modified files
16
+ *
17
+ * SAFETY GUARANTEES:
18
+ * - Dry-run by default shows full diff before any write
19
+ * - .gitignore updated BEFORE .env is written
20
+ * - Backs up originals to .praxis-backup/<timestamp>/ before touching
21
+ * - Atomic writes: temp file → rename, no partial writes
22
+ * - Verifies the fix worked by re-scanning before finalizing
23
+ * - Never prints actual secret values to stdout (masked in diff)
24
+ * - Sets .env to 0o600 (owner read/write only) on Unix
25
+ * - Warns if repository appears to be public
26
+ *
27
+ * RECOMMENDED ORDER:
28
+ * 1. praxis rotate — revoke the exposed key first
29
+ * 2. praxis remediate — fix source code
30
+ * 3. praxis purge-history — scrub git history (v4.0.0)
31
+ */
32
+
33
+ import fs from 'fs';
34
+ import path from 'path';
35
+ import os from 'os';
36
+ import { createInterface } from 'readline';
37
+ import { execSync, execFileSync } from 'child_process';
38
+ import chalk from 'chalk';
39
+ import ora from 'ora';
40
+ import writeFileAtomic from 'write-file-atomic';
41
+ import fg from 'fast-glob';
42
+ import {
43
+ SECRET_PATTERNS,
44
+ SKIP_DIRS,
45
+ SKIP_EXTENSIONS,
46
+ SKIP_FILENAMES,
47
+ TEST_FILE_PATTERNS,
48
+ MAX_FILE_SIZE
49
+ } from '../utils/patterns.js';
50
+ import { isHighEntropyMatch, getConfidence } from '../utils/entropy.js';
51
+ import * as output from '../utils/output.js';
52
+
53
+ // =============================================================================
54
+ // FRAMEWORK DETECTION
55
+ // =============================================================================
56
+
57
+ function detectFramework(rootPath) {
58
+ const pkgPath = path.join(rootPath, 'package.json');
59
+ if (fs.existsSync(pkgPath)) {
60
+ try {
61
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
62
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
63
+ if (deps['next']) return 'nextjs';
64
+ if (deps['nuxt'] || deps['nuxt3']) return 'nuxt';
65
+ } catch { /* ignore */ }
66
+ }
67
+ return 'node'; // default: Node.js / process.env
68
+ }
69
+
70
+ function envVarRef(varName, framework, filePath = '') {
71
+ if (filePath.endsWith('.py')) return `os.environ.get('${varName}')`;
72
+ if (filePath.endsWith('.rb')) return `ENV['${varName}']`;
73
+ // For Next.js keep standard process.env — user decides if NEXT_PUBLIC_ is needed
74
+ return `process.env.${varName}`;
75
+ }
76
+
77
+ // =============================================================================
78
+ // ENV VAR NAME GENERATION
79
+ // =============================================================================
80
+
81
+ /**
82
+ * Convert pattern name to SCREAMING_SNAKE_CASE env var name.
83
+ * e.g. "OpenAI API Key" → "OPENAI_API_KEY" // praxis-ignore — example name in doc comment, not a secret value
84
+ * "[custom] My Token" → "MY_TOKEN"
85
+ */
86
+ function patternToEnvVar(patternName) {
87
+ return patternName
88
+ .replace(/^\[custom\]\s*/i, '')
89
+ .toUpperCase()
90
+ .replace(/[^A-Z0-9\s]/g, '')
91
+ .trim()
92
+ .replace(/\s+/g, '_');
93
+ }
94
+
95
+ /**
96
+ * Ensure env var name is unique within the current session.
97
+ * If "OPENAI_API_KEY" is already taken, returns "OPENAI_API_KEY_2". // praxis-ignore — example in doc comment
98
+ */
99
+ function uniqueVarName(baseName, seen) {
100
+ if (!seen.has(baseName)) return baseName;
101
+ let i = 2;
102
+ while (seen.has(`${baseName}_${i}`)) i++;
103
+ return `${baseName}_${i}`;
104
+ }
105
+
106
+ // =============================================================================
107
+ // REPLACEMENT LOGIC
108
+ // =============================================================================
109
+
110
+ /**
111
+ * Compute what to replace in a line and extract the raw secret value.
112
+ *
113
+ * Given: matched = 'apiKey = "sk-abc123xyz"', envRef = 'process.env.OPENAI_API_KEY' // praxis-ignore — example in doc comment, no real secret
114
+ * Returns:
115
+ * replacement = 'apiKey = process.env.OPENAI_API_KEY' // praxis-ignore — example replacement in doc comment
116
+ * secretValue = 'sk-abc123xyz'
117
+ */
118
+ function computeReplacement(matched, envRef) {
119
+ // Case 1: quoted assignment — key = "value" or key: 'value'
120
+ const quotedAssignment = matched.match(/^(.*?[:=]\s*)["']([^"']{4,})["'](.*)$/s);
121
+ if (quotedAssignment) {
122
+ const [, prefix, secretValue, suffix] = quotedAssignment;
123
+ return { replacement: prefix + envRef + suffix, secretValue };
124
+ }
125
+
126
+ // Case 2: unquoted assignment — key = value (no quotes around value)
127
+ const unquotedAssignment = matched.match(/^(.*?[:=]\s*)([^\s"'<>\[\]{},;]{8,})(\s*)$/s);
128
+ if (unquotedAssignment) {
129
+ const [, prefix, secretValue, suffix] = unquotedAssignment;
130
+ return { replacement: prefix + envRef + suffix, secretValue };
131
+ }
132
+
133
+ // Case 3: raw secret with no assignment context (e.g. AKIA..., ghp_...)
134
+ return { replacement: envRef, secretValue: matched };
135
+ }
136
+
137
+ /**
138
+ * Apply a single replacement to a line at the exact column position.
139
+ * Uses column index to avoid regex issues with special characters.
140
+ */
141
+ function replaceInLine(line, matched, colIndex, replacement) {
142
+ const before = line.substring(0, colIndex);
143
+ const after = line.substring(colIndex + matched.length);
144
+ return before + replacement + after;
145
+ }
146
+
147
+ // =============================================================================
148
+ // PLAN BUILDING
149
+ // =============================================================================
150
+
151
+ /**
152
+ * Build a complete remediation plan from scan results.
153
+ * Returns an array of file-level plans, each with:
154
+ * - file: absolute path
155
+ * - originalLines: string[]
156
+ * - modifiedLines: string[]
157
+ * - changes: [{lineNum, originalLine, newLine, varName, secretValue}]
158
+ */
159
+ function buildPlan(scanResults, framework, rootPath) {
160
+ const plan = [];
161
+ const seenVarNames = new Set();
162
+
163
+ for (const { file, findings } of scanResults) {
164
+ const content = fs.readFileSync(file, 'utf-8');
165
+ const originalLines = content.split('\n');
166
+ const modifiedLines = [...originalLines];
167
+ const changes = [];
168
+
169
+ // Group findings by line, sort within each line by column descending
170
+ // so right-to-left replacements don't shift column indices for earlier matches
171
+ const byLine = {};
172
+ for (const f of findings) {
173
+ if (!byLine[f.line]) byLine[f.line] = [];
174
+ byLine[f.line].push(f);
175
+ }
176
+
177
+ let fileHasChanges = false;
178
+
179
+ for (const lineNumStr of Object.keys(byLine).sort((a, b) => Number(a) - Number(b))) {
180
+ const lineNum = Number(lineNumStr);
181
+ const lineFinders = byLine[lineNumStr].sort((a, b) => b.column - a.column);
182
+ let lineContent = modifiedLines[lineNum - 1];
183
+ const originalLine = originalLines[lineNum - 1];
184
+
185
+ for (const f of lineFinders) {
186
+ const baseVarName = patternToEnvVar(f.patternName);
187
+ const varName = uniqueVarName(baseVarName, seenVarNames);
188
+ seenVarNames.add(varName);
189
+
190
+ const ref = envVarRef(varName, framework, file);
191
+ const colIndex = f.column - 1;
192
+
193
+ const { replacement, secretValue } = computeReplacement(f.matched, ref);
194
+
195
+ lineContent = replaceInLine(lineContent, f.matched, colIndex, replacement);
196
+ changes.push({ lineNum, originalLine, newLine: lineContent, varName, secretValue });
197
+ fileHasChanges = true;
198
+ }
199
+
200
+ if (fileHasChanges) {
201
+ modifiedLines[lineNum - 1] = lineContent;
202
+ }
203
+ }
204
+
205
+ if (changes.length > 0) {
206
+ plan.push({ file, originalLines, modifiedLines, changes });
207
+ }
208
+ }
209
+
210
+ return plan;
211
+ }
212
+
213
+ // =============================================================================
214
+ // DIFF DISPLAY
215
+ // =============================================================================
216
+
217
+ function showDiff(planItem, rootPath) {
218
+ const relPath = path.relative(rootPath, planItem.file);
219
+ console.log('\n' + chalk.white.bold(` ${relPath}`));
220
+
221
+ for (const change of planItem.changes) {
222
+ console.log(chalk.gray(` Line ${change.lineNum}:`));
223
+ // Mask secret value in the diff output — never print raw secrets
224
+ const maskedOriginal = maskLine(change.originalLine);
225
+ console.log(chalk.red(` - ${maskedOriginal.trim()}`));
226
+ console.log(chalk.green(` + ${change.newLine.trim()}`));
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Mask what looks like a secret value in a line for safe display.
232
+ * Shows first 4 chars + asterisks so the user can identify which secret it is.
233
+ */
234
+ function maskLine(line) {
235
+ // Mask quoted strings that look like secrets (>8 chars of alphanum)
236
+ return line.replace(/["']([a-zA-Z0-9_\-+/=.]{8,})["']/g, (_, val) => {
237
+ const prefix = val.substring(0, 4);
238
+ return `"${prefix}${'*'.repeat(Math.min(val.length - 4, 12))}"`;
239
+ });
240
+ }
241
+
242
+ // =============================================================================
243
+ // CONFIRMATION PROMPT
244
+ // =============================================================================
245
+
246
+ async function confirm(question) {
247
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
248
+ return new Promise((resolve) => {
249
+ rl.question(chalk.yellow(`\n ${question} `), (answer) => {
250
+ rl.close();
251
+ const a = answer.trim().toLowerCase();
252
+ if (a === 's' || a === 'skip') resolve('skip');
253
+ else if (a === 'n' || a === 'no') resolve('no');
254
+ else resolve('yes');
255
+ });
256
+ });
257
+ }
258
+
259
+ // =============================================================================
260
+ // FILE BACKUP
261
+ // =============================================================================
262
+
263
+ function createBackupDir(rootPath) {
264
+ const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
265
+ const backupDir = path.join(rootPath, '.praxis-backup', ts);
266
+ fs.mkdirSync(backupDir, { recursive: true });
267
+ return backupDir;
268
+ }
269
+
270
+ function backupFile(filePath, backupDir, rootPath) {
271
+ const rel = path.relative(rootPath, filePath);
272
+ const dest = path.join(backupDir, rel);
273
+ const resolvedDest = path.resolve(dest);
274
+ const resolvedBackupDir = path.resolve(backupDir);
275
+ if (!resolvedDest.startsWith(resolvedBackupDir + path.sep) && resolvedDest !== resolvedBackupDir) {
276
+ throw new Error(`Path traversal detected: ${rel} escapes backup directory`);
277
+ }
278
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
279
+ fs.copyFileSync(filePath, dest);
280
+ }
281
+
282
+ // =============================================================================
283
+ // VERIFICATION
284
+ // =============================================================================
285
+
286
+ /**
287
+ * Re-scan the modified content string to verify secrets are gone.
288
+ * Returns true if clean, false if any of the original secrets still appear.
289
+ */
290
+ function verifyFixed(modifiedContent, changes) {
291
+ for (const change of changes) {
292
+ // Check that the original matched string is gone from the file
293
+ if (modifiedContent.includes(change.secretValue)) {
294
+ return false;
295
+ }
296
+ }
297
+ return true;
298
+ }
299
+
300
+ // =============================================================================
301
+ // ENV FILE MANAGEMENT
302
+ // =============================================================================
303
+
304
+ /**
305
+ * Append new env vars to .env file.
306
+ * - Creates .env if it doesn't exist
307
+ * - Skips vars that already exist in .env
308
+ * - Uses atomic write
309
+ * - Sets 0o600 permissions on Unix
310
+ */
311
+ async function writeEnvFile(rootPath, envVars) {
312
+ const envPath = path.join(rootPath, '.env');
313
+ let existing = '';
314
+
315
+ if (fs.existsSync(envPath)) {
316
+ existing = fs.readFileSync(envPath, 'utf-8');
317
+ }
318
+
319
+ const newLines = [];
320
+ const addedVars = [];
321
+
322
+ for (const [varName, secretValue] of Object.entries(envVars)) {
323
+ // Skip if already defined in .env
324
+ const alreadyDefined = new RegExp(`^${varName}=`, 'm').test(existing);
325
+ if (alreadyDefined) continue;
326
+
327
+ newLines.push(`${varName}=${secretValue}`);
328
+ addedVars.push(varName);
329
+ }
330
+
331
+ if (newLines.length === 0) return addedVars;
332
+
333
+ const separator = existing.endsWith('\n') || existing === '' ? '' : '\n';
334
+ const addition = separator + newLines.join('\n') + '\n';
335
+ const newContent = existing + addition;
336
+
337
+ await writeFileAtomic(envPath, newContent, { encoding: 'utf8' });
338
+
339
+ // Set restrictive permissions on Unix (no-op on Windows)
340
+ if (os.platform() !== 'win32') {
341
+ fs.chmodSync(envPath, 0o600);
342
+ }
343
+
344
+ return addedVars;
345
+ }
346
+
347
+ /**
348
+ * Ensure .env is in .gitignore.
349
+ * Adds it if missing. Called BEFORE writing .env.
350
+ */
351
+ function updateGitignore(rootPath) {
352
+ const gitignorePath = path.join(rootPath, '.gitignore');
353
+ let content = '';
354
+
355
+ if (fs.existsSync(gitignorePath)) {
356
+ content = fs.readFileSync(gitignorePath, 'utf-8');
357
+ }
358
+
359
+ const lines = content.split('\n').map(l => l.trim());
360
+ const hasEnv = lines.some(l => l === '.env' || l === '*.env');
361
+
362
+ if (!hasEnv) {
363
+ const addition = content.endsWith('\n') || content === ''
364
+ ? '.env\n'
365
+ : '\n.env\n';
366
+ fs.writeFileSync(gitignorePath, content + addition);
367
+ return true; // added
368
+ }
369
+ return false; // already present
370
+ }
371
+
372
+ /**
373
+ * Add placeholder entries to .env.example.
374
+ * Safe to call multiple times — skips vars already in the file.
375
+ */
376
+ function updateEnvExample(rootPath, envVars) {
377
+ const examplePath = path.join(rootPath, '.env.example');
378
+ let existing = '';
379
+
380
+ if (fs.existsSync(examplePath)) {
381
+ existing = fs.readFileSync(examplePath, 'utf-8');
382
+ }
383
+
384
+ const newLines = [];
385
+ for (const varName of Object.keys(envVars)) {
386
+ const alreadyDefined = new RegExp(`^${varName}=`, 'm').test(existing);
387
+ if (!alreadyDefined) {
388
+ newLines.push(`${varName}=your_${varName.toLowerCase()}_here`);
389
+ }
390
+ }
391
+
392
+ if (newLines.length === 0) return;
393
+
394
+ const separator = existing.endsWith('\n') || existing === '' ? '' : '\n';
395
+ fs.writeFileSync(examplePath, existing + separator + newLines.join('\n') + '\n');
396
+ }
397
+
398
+ // =============================================================================
399
+ // PUBLIC REPO WARNING
400
+ // =============================================================================
401
+
402
+ function checkPublicRepo(rootPath) {
403
+ try {
404
+ const remotes = execSync('git remote -v', { cwd: rootPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }); // praxis-ignore
405
+ if (remotes.includes('github.com') || remotes.includes('gitlab.com')) {
406
+ // We can't easily check visibility without an API call, so warn if it looks like a hosted repo
407
+ console.log();
408
+ console.log(chalk.yellow.bold(' âš  Heads up: this repo is hosted remotely.'));
409
+ console.log(chalk.yellow(' If secrets were already pushed, rotating them is more urgent than this fix.'));
410
+ console.log(chalk.yellow(' Run praxis rotate first if you haven\'t already.'));
411
+ }
412
+ } catch { /* Not a git repo or no remote — skip */ }
413
+ }
414
+
415
+ // =============================================================================
416
+ // GIT STAGING
417
+ // =============================================================================
418
+
419
+ function stageFiles(files, rootPath) {
420
+ if (files.length === 0) return;
421
+ try {
422
+ execFileSync('git', ['add', ...files], { cwd: rootPath, stdio: 'inherit' }); // praxis-ignore
423
+ output.success(`Staged ${files.length} file(s) with git add`);
424
+ } catch {
425
+ output.warning('Could not stage files — run git add manually.');
426
+ }
427
+ }
428
+
429
+ // =============================================================================
430
+ // SCAN (local, includes lineContent for replacement)
431
+ // =============================================================================
432
+
433
+ async function findFiles(rootPath) {
434
+ const globIgnore = Array.from(SKIP_DIRS).map(dir => `**/${dir}/**`);
435
+ const files = await fg('**/*', {
436
+ cwd: rootPath, absolute: true, onlyFiles: true, ignore: globIgnore, dot: true
437
+ });
438
+
439
+ const filtered = [];
440
+ for (const file of files) {
441
+ const ext = path.extname(file).toLowerCase();
442
+ if (SKIP_EXTENSIONS.has(ext)) continue;
443
+ if (SKIP_FILENAMES.has(path.basename(file))) continue;
444
+ const basename = path.basename(file);
445
+ if (basename.endsWith('.min.js') || basename.endsWith('.min.css')) continue;
446
+ if (TEST_FILE_PATTERNS.some(p => p.test(file))) continue;
447
+ if (basename === '.env' || basename === '.env.example') continue;
448
+ try {
449
+ const stats = fs.statSync(file);
450
+ if (stats.size > MAX_FILE_SIZE) continue;
451
+ } catch { continue; }
452
+ filtered.push(file);
453
+ }
454
+ return filtered;
455
+ }
456
+
457
+ async function scanFile(filePath) {
458
+ const findings = [];
459
+ try {
460
+ const content = fs.readFileSync(filePath, 'utf-8');
461
+ const lines = content.split('\n');
462
+
463
+ for (let lineNum = 0; lineNum < lines.length; lineNum++) {
464
+ const line = lines[lineNum];
465
+ if (/praxis-ignore/i.test(line)) continue;
466
+
467
+ for (const pattern of SECRET_PATTERNS) {
468
+ pattern.pattern.lastIndex = 0;
469
+ let match;
470
+ while ((match = pattern.pattern.exec(line)) !== null) {
471
+ if (pattern.requiresEntropyCheck && !isHighEntropyMatch(match[0])) continue;
472
+ findings.push({
473
+ line: lineNum + 1,
474
+ column: match.index + 1,
475
+ matched: match[0],
476
+ patternName: pattern.name,
477
+ severity: pattern.severity,
478
+ confidence: getConfidence(pattern, match[0]),
479
+ });
480
+ }
481
+ }
482
+ }
483
+ } catch { /* skip unreadable files */ }
484
+
485
+ return findings;
486
+ }
487
+
488
+ // =============================================================================
489
+ // AUTO-FIX AGENT FINDINGS (--all flag)
490
+ // =============================================================================
491
+
492
+ /**
493
+ * Apply automatic fixes for common agent findings:
494
+ * 1. Pin GitHub Actions to SHA (uses@tag → uses@sha)
495
+ * 2. Add httpOnly/secure/sameSite to cookie-setting code
496
+ * 3. Add USER directive to Dockerfiles without one
497
+ * 4. Disable debug mode (hardcoded debug → env var) praxis-ignore
498
+ *
499
+ * Returns array of human-readable fix descriptions.
500
+ */
501
+ async function autoFixAgentFindings(rootPath, options) { // praxis-ignore — function name, not an agent with elevated permissions
502
+ const fixes = [];
503
+
504
+ // ── 1. Pin GitHub Actions to commit SHA ─────────────────────────────
505
+ const workflowDir = path.join(rootPath, '.github', 'workflows');
506
+ if (fs.existsSync(workflowDir)) {
507
+ const yamlFiles = fs.readdirSync(workflowDir).filter(f => f.endsWith('.yml') || f.endsWith('.yaml'));
508
+ for (const file of yamlFiles) {
509
+ const filePath = path.join(workflowDir, file);
510
+ let content = fs.readFileSync(filePath, 'utf-8');
511
+ let modified = false;
512
+
513
+ // Match uses: owner/repo@v1.2.3 or uses: owner/repo@v1 (not already a SHA)
514
+ const usesRegex = /^(\s+uses:\s+)([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+)@(v?\d+[^\s#]*)/gm;
515
+ content = content.replace(usesRegex, (match, prefix, repo, tag) => {
516
+ // Skip if already pinned to SHA (40+ hex chars)
517
+ if (/^[0-9a-f]{40,}$/i.test(tag)) return match;
518
+ // Add a comment noting the original tag
519
+ modified = true;
520
+ return `${prefix}${repo}@${tag} # TODO: pin to SHA for supply chain safety`;
521
+ });
522
+
523
+ if (modified) {
524
+ fs.writeFileSync(filePath, content);
525
+ fixes.push(`.github/workflows/${file} — marked unpinned Actions for SHA pinning`);
526
+ }
527
+ }
528
+ }
529
+
530
+ // ── 2. Add httpOnly/secure/sameSite to cookie settings ──────────────
531
+ const cookieFiles = await fg('**/*.{js,ts,jsx,tsx,mjs}', {
532
+ cwd: rootPath, absolute: true, ignore: ['**/node_modules/**', '**/dist/**', '**/build/**'],
533
+ });
534
+
535
+ for (const filePath of cookieFiles.slice(0, 200)) {
536
+ try {
537
+ let content = fs.readFileSync(filePath, 'utf-8');
538
+ let modified = false;
539
+
540
+ // Pattern: res.cookie('name', value, { ... }) missing httpOnly
541
+ // Only fix if we see res.cookie with an options object that lacks httpOnly
542
+ const cookiePattern = /(res\.cookie\s*\([^)]*,\s*\{)([^}]*)(})/g;
543
+ content = content.replace(cookiePattern, (match, prefix, opts, suffix) => {
544
+ if (/httpOnly/i.test(opts)) return match; // already has it
545
+ modified = true;
546
+ const additions = [];
547
+ if (!/httpOnly/i.test(opts)) additions.push(' httpOnly: true');
548
+ if (!/secure/i.test(opts)) additions.push(' secure: true');
549
+ if (!/sameSite/i.test(opts)) additions.push(" sameSite: 'strict'");
550
+ const addStr = additions.length > 0 ? ',' + additions.join(',') : '';
551
+ return prefix + opts.trimEnd() + addStr + ' ' + suffix;
552
+ });
553
+
554
+ if (modified) {
555
+ fs.writeFileSync(filePath, content);
556
+ const rel = path.relative(rootPath, filePath);
557
+ fixes.push(`${rel} — added httpOnly/secure/sameSite to cookie options`);
558
+ }
559
+ } catch { /* skip */ }
560
+ }
561
+
562
+ // ── 3. Add USER directive to Dockerfiles ────────────────────────────
563
+ const dockerfiles = await fg('**/Dockerfile*', {
564
+ cwd: rootPath, absolute: true, ignore: ['**/node_modules/**'],
565
+ });
566
+
567
+ for (const filePath of dockerfiles) {
568
+ try {
569
+ let content = fs.readFileSync(filePath, 'utf-8');
570
+ if (/^\s*USER\s+/m.test(content)) continue; // already has USER
571
+
572
+ // Add USER before CMD/ENTRYPOINT
573
+ const cmdMatch = content.match(/^(CMD|ENTRYPOINT)\s/m);
574
+ if (cmdMatch) {
575
+ const idx = content.indexOf(cmdMatch[0]);
576
+ content = content.slice(0, idx) + 'USER 1001\n' + content.slice(idx);
577
+ fs.writeFileSync(filePath, content);
578
+ const rel = path.relative(rootPath, filePath);
579
+ fixes.push(`${rel} — added USER 1001 before CMD/ENTRYPOINT`);
580
+ }
581
+ } catch { /* skip */ }
582
+ }
583
+
584
+ // ── 4. Replace hardcoded debug settings with env var reference ──── praxis-ignore
585
+ const configFiles = await fg('**/*.{py,js,ts,env.example}', {
586
+ cwd: rootPath, absolute: true, ignore: ['**/node_modules/**', '**/dist/**', '**/.env'],
587
+ });
588
+
589
+ for (const filePath of configFiles.slice(0, 100)) {
590
+ try {
591
+ let content = fs.readFileSync(filePath, 'utf-8');
592
+ let modified = false;
593
+
594
+ if (filePath.endsWith('.py')) {
595
+ // Django/Flask: DEBUG=True → env var reference (praxis-ignore — regex pattern, not actual debug setting)
596
+ content = content.replace(/^(\s*DEBUG\s*=\s*)True\s*$/gm, (match, prefix) => {
597
+ modified = true;
598
+ return `${prefix}os.environ.get('DEBUG', 'False') == 'True'`;
599
+ });
600
+ } else { // praxis-ignore — regex pattern matching debug settings, not actual debug config
601
+ // JS/TS: debug:true → process.env.DEBUG reference
602
+ content = content.replace(/^(\s*(?:DEBUG|debug)\s*[:=]\s*)true\s*([,;]?\s*)$/gm, (match, prefix, suffix) => {
603
+ modified = true;
604
+ return `${prefix}process.env.DEBUG === 'true'${suffix}`;
605
+ });
606
+ }
607
+
608
+ if (modified) {
609
+ fs.writeFileSync(filePath, content);
610
+ const rel = path.relative(rootPath, filePath);
611
+ fixes.push(`${rel} — replaced hardcoded debug setting with env var`); // praxis-ignore
612
+ }
613
+ } catch { /* skip */ }
614
+ }
615
+
616
+ return fixes;
617
+ }
618
+
619
+ // =============================================================================
620
+ // MAIN COMMAND
621
+ // =============================================================================
622
+
623
+ export async function remediateCommand(targetPath = '.', options = {}) {
624
+ const absolutePath = path.resolve(targetPath);
625
+
626
+ if (!fs.existsSync(absolutePath)) {
627
+ output.error(`Path does not exist: ${absolutePath}`);
628
+ process.exit(1);
629
+ }
630
+
631
+ // ── 1. Scan ──────────────────────────────────────────────────────────────
632
+ const spinner = ora({ text: 'Scanning for secrets to remediate...', color: 'cyan' }).start();
633
+
634
+ const files = await findFiles(absolutePath);
635
+ const scanResults = [];
636
+
637
+ for (const file of files) {
638
+ const findings = await scanFile(file);
639
+ if (findings.length > 0) scanResults.push({ file, findings });
640
+ }
641
+
642
+ spinner.stop();
643
+
644
+ if (scanResults.length === 0) {
645
+ output.success('No secrets found — nothing to remediate!');
646
+ console.log(chalk.gray('\n Run praxis scan . to double-check.'));
647
+ return;
648
+ }
649
+
650
+ // ── 2. Build plan ─────────────────────────────────────────────────────────
651
+ const framework = detectFramework(absolutePath);
652
+ const plan = buildPlan(scanResults, framework, absolutePath);
653
+
654
+ const totalFindings = plan.reduce((sum, p) => sum + p.changes.length, 0);
655
+
656
+ output.header('Remediation Plan');
657
+ console.log(chalk.gray(`\n Framework detected: ${framework}`));
658
+ console.log(chalk.gray(` Found ${totalFindings} secret(s) in ${plan.length} file(s) to fix\n`));
659
+
660
+ // Show full diff for all files
661
+ for (const item of plan) {
662
+ showDiff(item, absolutePath);
663
+ }
664
+
665
+ // ── 3. Dry run ────────────────────────────────────────────────────────────
666
+ if (options.dryRun) {
667
+ console.log();
668
+ console.log(chalk.cyan('\n Dry run — no files modified.'));
669
+ console.log(chalk.gray(' Remove --dry-run to apply these changes.'));
670
+ return;
671
+ }
672
+
673
+ // ── 4. Warn if hosted remotely ────────────────────────────────────────────
674
+ checkPublicRepo(absolutePath);
675
+
676
+ // ── 5. Confirm before starting ────────────────────────────────────────────
677
+ if (!options.yes) {
678
+ const answer = await confirm(`Apply all ${totalFindings} fix(es)? [y/n]:`);
679
+ if (answer !== 'yes') {
680
+ console.log(chalk.gray('\n Aborted. No files were modified.'));
681
+ return;
682
+ }
683
+ }
684
+
685
+ // ── 6. Ensure .env is in .gitignore BEFORE writing .env ──────────────────
686
+ const addedToGitignore = updateGitignore(absolutePath);
687
+ if (addedToGitignore) {
688
+ output.success('Added .env to .gitignore');
689
+ }
690
+
691
+ // ── 7. Create backup directory ────────────────────────────────────────────
692
+ const backupDir = createBackupDir(absolutePath);
693
+
694
+ // ── 8. Process each file ──────────────────────────────────────────────────
695
+ const modifiedFiles = [];
696
+ const allEnvVars = {}; // varName → secretValue (deduplicated)
697
+
698
+ for (const item of plan) {
699
+ const relPath = path.relative(absolutePath, item.file);
700
+
701
+ // Per-file confirmation in interactive mode
702
+ if (!options.yes && plan.length > 1) {
703
+ showDiff(item, absolutePath);
704
+ const answer = await confirm(`Fix ${relPath}? [y/s(kip)/n(abort)]:`);
705
+ if (answer === 'skip') {
706
+ console.log(chalk.gray(` Skipped ${relPath}`));
707
+ continue;
708
+ }
709
+ if (answer === 'no') {
710
+ console.log(chalk.gray('\n Aborted. Previously fixed files are kept.'));
711
+ break;
712
+ }
713
+ }
714
+
715
+ // Backup original
716
+ backupFile(item.file, backupDir, absolutePath);
717
+
718
+ // Build modified content
719
+ const newContent = item.modifiedLines.join('\n');
720
+
721
+ // Verify the fix actually removes the secrets before writing
722
+ if (!verifyFixed(newContent, item.changes)) {
723
+ output.warning(`Verification failed for ${relPath} — skipping (original untouched)`);
724
+ continue;
725
+ }
726
+
727
+ // Atomic write
728
+ try {
729
+ await writeFileAtomic(item.file, newContent, { encoding: 'utf8' });
730
+ } catch (err) {
731
+ output.error(`Failed to write ${relPath}: ${err.message}`);
732
+ continue;
733
+ }
734
+
735
+ modifiedFiles.push(item.file);
736
+
737
+ // Collect env vars (first value wins for duplicates)
738
+ for (const change of item.changes) {
739
+ if (!(change.varName in allEnvVars)) {
740
+ allEnvVars[change.varName] = change.secretValue;
741
+ }
742
+ }
743
+
744
+ console.log(chalk.green(` ✓ Fixed ${relPath}`));
745
+ }
746
+
747
+ if (modifiedFiles.length === 0) {
748
+ console.log(chalk.yellow('\n No files were modified.'));
749
+ return;
750
+ }
751
+
752
+ // ── 9. Write .env ─────────────────────────────────────────────────────────
753
+ const addedVars = await writeEnvFile(absolutePath, allEnvVars);
754
+ if (addedVars.length > 0) {
755
+ output.success(`.env updated with ${addedVars.length} variable(s)`);
756
+ }
757
+
758
+ // ── 10. Update .env.example ───────────────────────────────────────────────
759
+ updateEnvExample(absolutePath, allEnvVars);
760
+ output.success('.env.example updated with placeholders');
761
+
762
+ // ── 11. Stage files if --stage ────────────────────────────────────────────
763
+ if (options.stage) {
764
+ stageFiles(modifiedFiles, absolutePath);
765
+ }
766
+
767
+ // ── 12. Auto-fix agent findings if --all ─────────────────────────────
768
+ if (options.all) {
769
+ const autoFixResults = await autoFixAgentFindings(absolutePath, options);
770
+ if (autoFixResults.length > 0) {
771
+ console.log();
772
+ output.success(`Auto-fixed ${autoFixResults.length} additional issue(s):`);
773
+ for (const r of autoFixResults) {
774
+ console.log(chalk.gray(` ✓ ${r}`));
775
+ }
776
+ if (options.stage) {
777
+ stageFiles(autoFixResults.map(r => r.split(' — ')[0]).filter(f => fs.existsSync(path.resolve(absolutePath, f))), absolutePath);
778
+ }
779
+ }
780
+ }
781
+
782
+ // ── 13. Summary ───────────────────────────────────────────────────────────
783
+ console.log();
784
+ console.log(chalk.cyan.bold(' Remediation complete'));
785
+ console.log(chalk.gray(` Files fixed: ${modifiedFiles.length}`));
786
+ console.log(chalk.gray(` Env vars added: ${addedVars.length}`));
787
+ console.log(chalk.gray(` Backup saved to: .praxis-backup/`));
788
+
789
+ console.log();
790
+ console.log(chalk.yellow.bold(' Next steps — do these in order:'));
791
+ console.log(chalk.white(' 1.') + chalk.gray(' Rotate your exposed keys immediately (praxis rotate)'));
792
+ console.log(chalk.white(' 2.') + chalk.gray(' Commit the fixed files: git add . && git commit -m "fix: remove hardcoded secrets"'));
793
+ console.log(chalk.white(' 3.') + chalk.gray(' Copy .env.example → .env and fill in fresh values'));
794
+ console.log(chalk.white(' 4.') + chalk.gray(' Run praxis scan . to verify everything is clean'));
795
+ console.log(chalk.white(' 5.') + chalk.gray(' If secrets were already pushed, also purge git history'));
796
+ console.log();
797
+ }