praxis-sec 1.2.0 → 1.2.1

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 (49) hide show
  1. package/cli/agents/abom-generator.js +1 -1
  2. package/cli/agents/agent-attestation-agent.js +10 -1
  3. package/cli/agents/agent-config-scanner.js +1 -1
  4. package/cli/agents/ai-infra-inventory-agent.js +482 -482
  5. package/cli/agents/base-agent.js +1 -1
  6. package/cli/agents/endpoint-agent-abuse-agent.js +1 -1
  7. package/cli/agents/html-reporter.js +3 -3
  8. package/cli/agents/index.js +2 -2
  9. package/cli/agents/injection-tester.js +8 -1
  10. package/cli/agents/memory-poisoning-agent.js +1 -1
  11. package/cli/agents/model-file-scanner.js +1 -1
  12. package/cli/agents/orchestrator.js +11 -6
  13. package/cli/agents/prompt-injection-prober.js +228 -228
  14. package/cli/bin/praxis.js +7 -3
  15. package/cli/commands/agent-fix.js +1091 -1245
  16. package/cli/commands/audit.js +1228 -1216
  17. package/cli/commands/baseline.js +1 -1
  18. package/cli/commands/benchmark.js +1 -1
  19. package/cli/commands/ci.js +45 -21
  20. package/cli/commands/deps.js +11 -5
  21. package/cli/commands/env-audit.js +1 -1
  22. package/cli/commands/fix.js +1 -1
  23. package/cli/commands/mcp.js +1 -1
  24. package/cli/commands/red-team.js +350 -350
  25. package/cli/commands/remediate.js +1 -1
  26. package/cli/commands/rotate.js +1 -1
  27. package/cli/commands/rules.js +1 -1
  28. package/cli/commands/scan.js +554 -554
  29. package/cli/commands/score.js +1 -1
  30. package/cli/commands/undo.js +22 -77
  31. package/cli/commands/vibe-check.js +1 -1
  32. package/cli/core/fix-plan.js +274 -0
  33. package/cli/core/fs.js +27 -0
  34. package/cli/core/git-clone.js +8 -6
  35. package/cli/core/glob.js +56 -0
  36. package/cli/core/output/html-theme.js +158 -158
  37. package/cli/core/output/sarif.js +2 -2
  38. package/cli/core/web/jobs.js +2 -2
  39. package/cli/core/web/server.js +19 -8
  40. package/cli/data/threatpacks/latest.json +41 -41
  41. package/cli/integrations/github-action.js +136 -0
  42. package/cli/utils/plugin-loader.js +15 -95
  43. package/cli/utils/rule-import.js +227 -227
  44. package/cli/utils/rule-registry.js +425 -425
  45. package/cli/utils/scan-fingerprint.js +1 -1
  46. package/cli/utils/score-history.js +118 -118
  47. package/docs/USAGE.md +16 -9
  48. package/docs/design/WEB-UI.md +4 -5
  49. package/package.json +13 -4
@@ -28,7 +28,7 @@
28
28
 
29
29
  import fs from 'fs';
30
30
  import path from 'path';
31
- import fg from 'fast-glob';
31
+ import fg from '../core/glob.js';
32
32
  import chalk from 'chalk';
33
33
  import ora from 'ora';
34
34
  import {
@@ -5,8 +5,8 @@
5
5
  * Reverts changes applied by `praxis fix interactive`.
6
6
  *
7
7
  * Reads .praxis/fixes.jsonl, takes the most recent entry (or all entries
8
- * with --all), and reverses each edit. Per-fix git commits made by the agent
9
- * are preferred over manual reversal when available.
8
+ * with --all), and reverses the recorded edits without changing unrelated files
9
+ * or the Git index. Failed reversals retain their log entry for a later retry.
10
10
  *
11
11
  * USAGE:
12
12
  * praxis undo Revert the last applied fix
@@ -16,7 +16,8 @@
16
16
 
17
17
  import fs from 'fs';
18
18
  import path from 'path';
19
- import { execFileSync } from 'child_process';
19
+ import writeFileAtomic from 'write-file-atomic';
20
+ import { reversePlan } from '../core/fix-plan.js';
20
21
  import chalk from 'chalk';
21
22
  import * as output from '../utils/output.js';
22
23
 
@@ -32,11 +33,15 @@ export async function undoCommand(targetPath = '.', options = {}) {
32
33
  process.exit(1);
33
34
  }
34
35
 
35
- const entries = fs.readFileSync(logPath, 'utf8')
36
- .split('\n')
37
- .filter(Boolean)
38
- .map(line => { try { return JSON.parse(line); } catch { return null; } })
39
- .filter(Boolean);
36
+ let entries;
37
+ try {
38
+ entries = fs.readFileSync(logPath, 'utf8').split('\n').filter(line => line.trim()).map(line => JSON.parse(line));
39
+ if (entries.some(entry => !entry || typeof entry !== 'object' || Array.isArray(entry))) throw new Error('invalid entry');
40
+ } catch {
41
+ output.error('Fix log is malformed; repair it before undoing. No files were changed.');
42
+ process.exitCode = 1;
43
+ return;
44
+ }
40
45
 
41
46
  if (entries.length === 0) {
42
47
  output.error('Fix log is empty.');
@@ -53,6 +58,7 @@ export async function undoCommand(targetPath = '.', options = {}) {
53
58
 
54
59
  let reverted = 0;
55
60
  let failed = 0;
61
+ const undone = new Set();
56
62
 
57
63
  for (const entry of toUndo) {
58
64
  const file = entry.file || entry.finding?.file || '(unknown)';
@@ -65,22 +71,25 @@ export async function undoCommand(targetPath = '.', options = {}) {
65
71
  }
66
72
 
67
73
  try {
68
- reverseEntry(root, entry);
74
+ reversePlan(root, entry.plan);
69
75
  console.log(chalk.green(' Reverted.'));
70
76
  reverted++;
77
+ undone.add(entry);
71
78
  } catch (err) {
72
79
  console.log(chalk.red(` Failed: ${err.message}`));
73
80
  failed++;
81
+ // Older fixes may depend on this one. Stop so the log remains a valid stack.
82
+ break;
74
83
  }
75
84
  }
76
85
 
77
86
  // Truncate the log
78
87
  if (!options.dryRun && reverted > 0) {
79
- const remaining = options.all ? [] : entries.slice(0, -1);
88
+ const remaining = entries.filter(entry => !undone.has(entry));
80
89
  if (remaining.length === 0) {
81
90
  fs.unlinkSync(logPath);
82
91
  } else {
83
- fs.writeFileSync(logPath, remaining.map(e => JSON.stringify(e)).join('\n') + '\n', 'utf8');
92
+ writeFileAtomic.sync(logPath, remaining.map(e => JSON.stringify(e)).join('\n') + '\n', { encoding: 'utf8' });
84
93
  }
85
94
  }
86
95
 
@@ -90,72 +99,8 @@ export async function undoCommand(targetPath = '.', options = {}) {
90
99
  console.log();
91
100
 
92
101
  if (failed > 0) {
93
- console.log(chalk.gray(' For failed entries, try `git checkout` or `git reset --hard` if you committed via --branch.'));
102
+ process.exitCode = 1;
103
+ console.log(chalk.gray(' Failed and unattempted fixes remain in the log. Review the changed files before retrying.'));
94
104
  console.log();
95
105
  }
96
106
  }
97
-
98
- function isGitRepo(dir) {
99
- try {
100
- execFileSync('git', ['-C', dir, 'rev-parse', '--is-inside-work-tree'], { stdio: 'ignore' });
101
- return true;
102
- } catch {
103
- return false;
104
- }
105
- }
106
-
107
- function reverseEntry(root, entry) {
108
- if (entry.commitHash && isGitRepo(root)) {
109
- try {
110
- execFileSync('git', ['-C', root, 'revert', '--no-commit', entry.commitHash], { stdio: 'ignore' });
111
- return;
112
- } catch {
113
- console.log(chalk.yellow(` Git revert failed for ${entry.commitHash.slice(0, 7)}. Falling back to manual text reversal.`));
114
- }
115
- }
116
-
117
- const plan = entry.plan;
118
- if (!plan || !Array.isArray(plan.files) || plan.files.length === 0) {
119
- throw new Error('entry has no plan to reverse');
120
- }
121
-
122
- for (const fileChange of plan.files) {
123
- const abs = path.resolve(root, fileChange.path);
124
-
125
- if (fileChange.create) {
126
- // We created the file — delete it
127
- if (fs.existsSync(abs)) fs.unlinkSync(abs);
128
- continue;
129
- }
130
-
131
- if (fileChange.append !== undefined) {
132
- if (!fs.existsSync(abs)) continue;
133
- const current = fs.readFileSync(abs, 'utf8');
134
- // Try to remove the appended text (it may be at the end)
135
- const idx = current.lastIndexOf(fileChange.append);
136
- if (idx === -1) {
137
- throw new Error(`appended text not found in ${fileChange.path}`);
138
- }
139
- const reverted = current.slice(0, idx) + current.slice(idx + fileChange.append.length);
140
- fs.writeFileSync(abs, reverted, 'utf8');
141
- continue;
142
- }
143
-
144
- // Standard edits — reverse find/replace
145
- if (!fs.existsSync(abs)) {
146
- throw new Error(`file no longer exists: ${fileChange.path}`);
147
- }
148
- let content = fs.readFileSync(abs, 'utf8');
149
- // Reverse in opposite order in case edits are positionally dependent
150
- const reversed = [...fileChange.edits].reverse();
151
- for (const e of reversed) {
152
- const newStr = e.replace;
153
- const oldStr = e._resolvedFind || e.find;
154
- if (!content.includes(newStr)) {
155
- throw new Error(`reverted text not found in ${fileChange.path} (file changed since fix)`);
156
- }
157
- content = content.replace(newStr, oldStr);
158
- }
159
- fs.writeFileSync(abs, content, 'utf8');
160
- }
161
- }
@@ -30,7 +30,7 @@ import {
30
30
  loadGitignorePatterns
31
31
  } from '../utils/patterns.js';
32
32
  import { isHighEntropyMatch, getConfidence } from '../utils/entropy.js';
33
- import fg from 'fast-glob';
33
+ import fg from '../core/glob.js';
34
34
 
35
35
  // =============================================================================
36
36
  // VIBES DATA
@@ -0,0 +1,274 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import writeFileAtomic from 'write-file-atomic';
4
+ import { createHash } from 'crypto';
5
+ import { resolveProjectFile } from './fs.js';
6
+
7
+ const NEVER_EDIT = [
8
+ /(^|\/)\.env(\.|$)/i,
9
+ /\.pem$|\.key$|\.p12$|\.pfx$/i,
10
+ /package-lock\.json$|yarn\.lock$|pnpm-lock\.yaml$/i,
11
+ /(^|\/)node_modules\//,
12
+ /(^|\/)dist\//,
13
+ /(^|\/)build\//,
14
+ /\.min\.(js|css)$/,
15
+ ];
16
+ // Files the agent IS allowed to create or update freely (companions to fixes)
17
+ const SAFE_NEW_FILES = [
18
+ /(^|\/)\.env\.example$/i,
19
+ /(^|\/)\.env\.sample$/i,
20
+ /(^|\/)\.gitignore$/i,
21
+ ];
22
+
23
+ export function isProtectedFixPath(relative) {
24
+ const rel = relative.replace(/\\/g, '/');
25
+ return /(^|\/)(\.git|\.praxis|node_modules|dist|build)(\/|$)/i.test(rel) ||
26
+ (!SAFE_NEW_FILES.some(pattern => pattern.test(rel)) && NEVER_EDIT.some(pattern => pattern.test(rel)));
27
+ }
28
+
29
+ export function validatePlan(root, plan) {
30
+ try {
31
+ return validateFiles(root, plan);
32
+ } catch (err) {
33
+ return { ok: false, reason: err.message };
34
+ }
35
+ }
36
+
37
+ function validateFiles(root, plan) {
38
+ if (!plan || !Array.isArray(plan.files) || plan.files.length === 0) {
39
+ return { ok: false, reason: 'no files in plan' };
40
+ }
41
+
42
+ const paths = new Set();
43
+ for (const f of plan.files) {
44
+ if (!f || typeof f.path !== 'string' || !f.path) return { ok: false, reason: 'file entry missing path' };
45
+
46
+ const abs = resolveProjectFile(root, f.path);
47
+ const key = process.platform === 'win32' ? abs.toLowerCase() : abs;
48
+ if (paths.has(key)) return { ok: false, reason: `duplicate file: ${f.path}` };
49
+ paths.add(key);
50
+ const rel = path.relative(fs.realpathSync(root), abs).replace(/\\/g, '/');
51
+ const isSafeNew = SAFE_NEW_FILES.some(p => p.test(rel));
52
+
53
+ // Block protected paths unless this is a known-safe companion file
54
+ if (isProtectedFixPath(rel)) {
55
+ return { ok: false, reason: `protected path: ${f.path}` };
56
+ }
57
+
58
+ const exists = fs.existsSync(abs);
59
+ if (exists && !fs.statSync(abs).isFile()) return { ok: false, reason: `not a file: ${f.path}` };
60
+
61
+ // Companion file forms (create / append)
62
+ if (f.create || f.append !== undefined) {
63
+ if (!exists && !isSafeNew) {
64
+ return { ok: false, reason: `cannot create new file at ${f.path}` };
65
+ }
66
+ if (f.create && typeof f.content !== 'string') {
67
+ return { ok: false, reason: 'create entry missing content' };
68
+ }
69
+ if (f.create && exists) return { ok: false, reason: `cannot overwrite existing file: ${f.path}` };
70
+ if (f.create && f.append !== undefined) return { ok: false, reason: 'create and append cannot be combined' };
71
+ if (f.append !== undefined && (typeof f.append !== 'string' || !f.append.trim())) {
72
+ return { ok: false, reason: 'append must be a string' };
73
+ }
74
+ continue;
75
+ }
76
+
77
+ // Standard edit form
78
+ if (!exists) return { ok: false, reason: `file not found: ${f.path}` };
79
+ if (!Array.isArray(f.edits) || f.edits.length === 0) {
80
+ return { ok: false, reason: `no edits for ${f.path}` };
81
+ }
82
+
83
+ let content = fs.readFileSync(abs, 'utf8');
84
+ for (const e of f.edits) {
85
+ if (!e || typeof e.find !== 'string' || !e.find || typeof e.replace !== 'string') {
86
+ return { ok: false, reason: 'edit missing find/replace' };
87
+ }
88
+ if (e.find === e.replace) {
89
+ return { ok: false, reason: 'edit is a no-op' };
90
+ }
91
+ const match = locateFindString(content, e.find);
92
+ if (match.kind === 'missing') {
93
+ return { ok: false, reason: `find string not present in ${f.path}` };
94
+ }
95
+ if (match.kind === 'ambiguous') {
96
+ return { ok: false, reason: `find string is ambiguous (${match.count} matches) in ${f.path}` };
97
+ }
98
+ // Annotate the edit with the resolved match for use during apply
99
+ e._resolvedFind = match.matched;
100
+ content = content.replace(match.matched, () => e.replace);
101
+ }
102
+ }
103
+ return { ok: true };
104
+ }
105
+
106
+ // Try exact match first, then whitespace-normalized match if exact misses.
107
+ // Returns { kind: 'unique'|'ambiguous'|'missing', matched, count }
108
+ function locateFindString(haystack, needle) {
109
+ const exact = countOccurrences(haystack, needle);
110
+ if (exact === 1) return { kind: 'unique', matched: needle, count: 1 };
111
+ if (exact > 1) return { kind: 'ambiguous', matched: needle, count: exact };
112
+
113
+ // Whitespace-tolerant fallback: collapse whitespace runs and try again
114
+ const norm = (s) => s.replace(/[ \t]+/g, ' ').replace(/\s*\n\s*/g, '\n').trim();
115
+ const needleNorm = norm(needle);
116
+ if (!needleNorm) return { kind: 'missing', matched: null, count: 0 };
117
+
118
+ // Walk the haystack and check if any window normalizes to the same string
119
+ // To keep this cheap, only attempt when needle has at least one newline (likely a code block)
120
+ const lines = haystack.split('\n');
121
+ const needleLines = needleNorm.split('\n').length;
122
+ let foundIdx = -1;
123
+ let foundCount = 0;
124
+ for (let i = 0; i + needleLines <= lines.length; i++) {
125
+ const window = lines.slice(i, i + needleLines).join('\n');
126
+ if (norm(window) === needleNorm) {
127
+ foundIdx = i;
128
+ foundCount++;
129
+ if (foundCount > 1) break;
130
+ }
131
+ }
132
+ if (foundCount === 1) {
133
+ const matched = lines.slice(foundIdx, foundIdx + needleLines).join('\n');
134
+ return { kind: 'unique', matched, count: 1 };
135
+ }
136
+ if (foundCount > 1) return { kind: 'ambiguous', matched: null, count: foundCount };
137
+ return { kind: 'missing', matched: null, count: 0 };
138
+ }
139
+
140
+ function countOccurrences(haystack, needle) {
141
+ if (!needle) return 0;
142
+ let count = 0, idx = 0;
143
+ while ((idx = haystack.indexOf(needle, idx)) !== -1) { count++; idx += needle.length; }
144
+ return count;
145
+ }
146
+
147
+ function applyEdit(root, fileChange) {
148
+ const abs = resolveProjectFile(root, fileChange.path);
149
+
150
+ if (fileChange.create) {
151
+ if (fs.existsSync(abs)) throw new Error(`cannot overwrite existing file: ${fileChange.path}`);
152
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
153
+ writeFileAtomic.sync(abs, fileChange.content, { encoding: 'utf8' });
154
+ fileChange._afterHash = hashContent(fileChange.content);
155
+ return;
156
+ }
157
+
158
+ if (fileChange.append !== undefined) {
159
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
160
+ const existed = fs.existsSync(abs);
161
+ const existing = existed ? fs.readFileSync(abs, 'utf8') : '';
162
+ fileChange._createdByAppend = !existed;
163
+ fileChange._appendApplied = '';
164
+ // Avoid duplicate appends
165
+ if (existing.includes(fileChange.append.trim())) return;
166
+ const sep = existing && !existing.endsWith('\n') ? '\n' : '';
167
+ fileChange._appendApplied = sep + fileChange.append;
168
+ const updated = existing + fileChange._appendApplied;
169
+ writeFileAtomic.sync(abs, updated, { encoding: 'utf8' });
170
+ fileChange._afterHash = hashContent(updated);
171
+ return;
172
+ }
173
+
174
+ let content = fs.readFileSync(abs, 'utf8');
175
+ for (const e of fileChange.edits) {
176
+ const find = e._resolvedFind || e.find;
177
+ if (countOccurrences(content, find) !== 1) {
178
+ throw new Error(`find string drifted in ${fileChange.path} (file changed mid-plan)`);
179
+ }
180
+ e._offset = content.indexOf(find);
181
+ content = content.replace(find, () => e.replace);
182
+ }
183
+ writeFileAtomic.sync(abs, content, { encoding: 'utf8' });
184
+ fileChange._afterHash = hashContent(content);
185
+ }
186
+
187
+ const hashContent = content => createHash('sha256').update(content).digest('hex');
188
+
189
+ export function snapshotPlan(root, plan) {
190
+ return plan.files.map(file => {
191
+ const abs = resolveProjectFile(root, file.path);
192
+ const existed = fs.existsSync(abs);
193
+ return { abs, existed, content: existed ? fs.readFileSync(abs) : null };
194
+ });
195
+ }
196
+
197
+ /** Restore every file or report exactly which restorations failed. */
198
+ export function restoreSnapshots(snapshots) {
199
+ const failures = [];
200
+ for (const snapshot of snapshots) {
201
+ try {
202
+ if (snapshot.existed) writeFileAtomic.sync(snapshot.abs, snapshot.content);
203
+ else if (fs.existsSync(snapshot.abs)) fs.unlinkSync(snapshot.abs);
204
+ } catch (err) {
205
+ failures.push(`${snapshot.abs}: ${err.message}`);
206
+ }
207
+ }
208
+ if (failures.length) throw new Error(`restore failed: ${failures.join('; ')}`);
209
+ }
210
+
211
+ /** Validate again after approval, then roll back any partially applied plan. */
212
+ export function applyPlan(root, plan) {
213
+ const validation = validatePlan(root, plan);
214
+ if (!validation.ok) throw new Error(validation.reason);
215
+ const snapshots = snapshotPlan(root, plan);
216
+ try {
217
+ for (const file of plan.files) applyEdit(root, file);
218
+ return snapshots;
219
+ } catch (err) {
220
+ try {
221
+ restoreSnapshots(snapshots);
222
+ } catch (restoreError) {
223
+ throw new Error(`${err.message}; ${restoreError.message}`);
224
+ }
225
+ throw err;
226
+ }
227
+ }
228
+
229
+ /** Preflight every reverse edit before touching any file; preserve later user edits. */
230
+ export function reversePlan(root, plan) {
231
+ if (!plan || !Array.isArray(plan.files) || !plan.files.length) throw new Error('entry has no plan to reverse');
232
+ const changes = plan.files.map(file => {
233
+ const abs = resolveProjectFile(root, file.path);
234
+ if (isProtectedFixPath(path.relative(fs.realpathSync(root), abs))) throw new Error(`protected path: ${file.path}`);
235
+ const current = fs.readFileSync(abs, 'utf8');
236
+ if (file._afterHash && hashContent(current) !== file._afterHash) {
237
+ throw new Error(`file changed since fix: ${file.path}`);
238
+ }
239
+ if (file.create || file._createdByAppend) {
240
+ const expected = file.create ? file.content : file._appendApplied;
241
+ if (current !== expected) throw new Error(`created file changed since fix: ${file.path}`);
242
+ return { abs, remove: true };
243
+ }
244
+ if (file.append !== undefined) {
245
+ const appended = file._appendApplied ?? file.append;
246
+ if (!appended) return { abs, content: current };
247
+ if (!current.endsWith(appended)) throw new Error(`appended text no longer at end of ${file.path}`);
248
+ return { abs, content: current.slice(0, -appended.length) };
249
+ }
250
+ if (!Array.isArray(file.edits)) throw new Error(`no edits to reverse: ${file.path}`);
251
+ let content = current;
252
+ for (const edit of [...file.edits].reverse()) {
253
+ const original = edit._resolvedFind ?? edit.find;
254
+ if (typeof edit.replace !== 'string' || typeof original !== 'string') throw new Error('invalid reverse edit');
255
+ const offset = file._afterHash && Number.isInteger(edit._offset) ? edit._offset : content.indexOf(edit.replace);
256
+ if ((!file._afterHash && countOccurrences(content, edit.replace) !== 1) || offset < 0 ||
257
+ content.slice(offset, offset + edit.replace.length) !== edit.replace) {
258
+ throw new Error(`reverted text missing or ambiguous in ${file.path}`);
259
+ }
260
+ content = content.slice(0, offset) + original + content.slice(offset + edit.replace.length);
261
+ }
262
+ return { abs, content };
263
+ });
264
+ const snapshots = snapshotPlan(root, plan);
265
+ try {
266
+ for (const change of changes) {
267
+ if (change.remove) fs.unlinkSync(change.abs);
268
+ else writeFileAtomic.sync(change.abs, change.content, { encoding: 'utf8' });
269
+ }
270
+ } catch (err) {
271
+ restoreSnapshots(snapshots);
272
+ throw err;
273
+ }
274
+ }
package/cli/core/fs.js CHANGED
@@ -60,3 +60,30 @@ export function ensureDir(dir) {
60
60
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
61
61
  return dir;
62
62
  }
63
+
64
+ /** Resolve a write target inside a project, including existing symlink parents. */
65
+ export function resolveProjectFile(root, candidate) {
66
+ if (typeof candidate !== 'string' || !candidate || candidate.includes('\0')) {
67
+ throw new Error('file path must be a non-empty string');
68
+ }
69
+ const project = fs.realpathSync(root);
70
+ const inside = target => {
71
+ const relative = path.relative(project, target);
72
+ return relative && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
73
+ };
74
+ const absolute = path.resolve(project, candidate);
75
+ if (!inside(absolute)) throw new Error(`path escapes project: ${candidate}`);
76
+ let existing = absolute;
77
+ while (!fs.existsSync(existing)) {
78
+ // A dangling symlink is an existing directory entry and must not be followed.
79
+ try {
80
+ if (fs.lstatSync(existing).isSymbolicLink()) throw new Error(`dangling symlink: ${candidate}`);
81
+ } catch (err) {
82
+ if (err.code !== 'ENOENT') throw err;
83
+ }
84
+ existing = path.dirname(existing);
85
+ }
86
+ const resolved = path.join(fs.realpathSync(existing), path.relative(existing, absolute));
87
+ if (!inside(resolved)) throw new Error(`symlink escapes project: ${candidate}`);
88
+ return resolved;
89
+ }
@@ -79,11 +79,11 @@ export function parseGitUrl(target = '', options = {}) {
79
79
  raw = `https://${raw}`;
80
80
  }
81
81
 
82
- let provider = 'git';
83
- if (/github\.com/i.test(raw)) provider = 'github';
84
- else if (/gitlab\.com/i.test(raw)) provider = 'gitlab';
85
- else if (/bitbucket\.org/i.test(raw)) provider = 'bitbucket';
86
- else if (/dev\.azure\.com/i.test(raw)) provider = 'azure';
82
+ let hostname = raw.match(/^git@([^:]+):/)?.[1]?.toLowerCase() || '';
83
+ try { hostname = new URL(raw).hostname.toLowerCase(); } catch { /* local clone path or SCP syntax */ }
84
+ const provider = {
85
+ 'github.com': 'github', 'gitlab.com': 'gitlab', 'bitbucket.org': 'bitbucket', 'dev.azure.com': 'azure',
86
+ }[hostname] || 'git';
87
87
 
88
88
  // Extract clean repository name
89
89
  let repoName = 'repository';
@@ -95,7 +95,9 @@ export function parseGitUrl(target = '', options = {}) {
95
95
  const displayUrl = redactGitUrl(raw);
96
96
 
97
97
  // Auth token injection for private repositories (if requested)
98
- const token = options.gitToken || process.env.PRAXIS_GIT_TOKEN || process.env.GITHUB_TOKEN || process.env.GIT_TOKEN;
98
+ // A GitHub credential must never be forwarded to another Git host or a lookalike.
99
+ const token = options.gitToken || process.env.PRAXIS_GIT_TOKEN || process.env.GIT_TOKEN ||
100
+ (hostname === 'github.com' ? process.env.GITHUB_TOKEN : undefined);
99
101
  let cloneUrl = raw;
100
102
 
101
103
  if (token && raw.startsWith('https://')) {
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Scanner file discovery boundary. Target-controlled patterns are bounded before
3
+ * fast-glob/braces parse them (GHSA-vfj7-8cjw-p6xm has no upstream patch yet).
4
+ * Directory symlinks must not turn a project scan into a scan of the host.
5
+ */
6
+ import fastGlob from 'fast-glob';
7
+
8
+ const MAX_PATTERNS = 4096;
9
+ const MAX_LENGTH = 8192;
10
+ const MAX_DEPTH = 16;
11
+ const MAX_EXPANSIONS = 1024;
12
+
13
+ export function validateGlobPatterns(patterns) {
14
+ const list = Array.isArray(patterns) ? patterns : [patterns];
15
+ if (list.length > MAX_PATTERNS) throw new Error('Too many glob patterns');
16
+ for (const pattern of list) {
17
+ if (typeof pattern !== 'string' || pattern.length > MAX_LENGTH) throw new Error('Invalid or oversized glob pattern');
18
+ const stack = [];
19
+ let expansions = 1;
20
+ for (let i = 0; i < pattern.length; i++) {
21
+ const char = pattern[i];
22
+ if (char === '\\') { i++; continue; }
23
+ if (char === '{') {
24
+ stack.push({ start: i, alternatives: 1 });
25
+ if (stack.length > MAX_DEPTH) throw new Error('Glob brace nesting exceeds safe limit');
26
+ } else if (char === ',' && stack.length) {
27
+ stack[stack.length - 1].alternatives++;
28
+ } else if (char === '}' && stack.length) {
29
+ const group = stack.pop();
30
+ const range = pattern.slice(group.start + 1, i).match(/^(-?\d+|[a-zA-Z])\.\.(-?\d+|[a-zA-Z])(?:\.\.(-?\d+))?$/);
31
+ let alternatives = group.alternatives;
32
+ if (range) {
33
+ const number = value => /^-?\d+$/.test(value) ? Number(value) : value.charCodeAt(0);
34
+ const step = Math.abs(Number(range[3] || 1));
35
+ alternatives = Math.floor(Math.abs(number(range[2]) - number(range[1])) / step) + 1;
36
+ }
37
+ expansions *= alternatives;
38
+ if (!Number.isFinite(expansions) || expansions > MAX_EXPANSIONS) throw new Error('Glob brace expansion exceeds safe limit');
39
+ }
40
+ }
41
+ }
42
+ }
43
+
44
+ function safeOptions(patterns, options) {
45
+ validateGlobPatterns(patterns);
46
+ if (options.ignore) validateGlobPatterns(options.ignore);
47
+ return { ...options, followSymbolicLinks: false };
48
+ }
49
+
50
+ function glob(patterns, options = {}) {
51
+ return fastGlob(patterns, safeOptions(patterns, options));
52
+ }
53
+
54
+ glob.sync = (patterns, options = {}) => fastGlob.sync(patterns, safeOptions(patterns, options));
55
+
56
+ export default glob;