mustflow 2.84.0 → 2.99.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 (105) hide show
  1. package/README.md +11 -2
  2. package/dist/cli/commands/script-pack.js +14 -0
  3. package/dist/cli/i18n/en.js +262 -0
  4. package/dist/cli/i18n/es.js +262 -0
  5. package/dist/cli/i18n/fr.js +262 -0
  6. package/dist/cli/i18n/hi.js +262 -0
  7. package/dist/cli/i18n/ko.js +262 -0
  8. package/dist/cli/i18n/zh.js +262 -0
  9. package/dist/cli/lib/repo-map.js +27 -6
  10. package/dist/cli/lib/run-root-trust.js +15 -1
  11. package/dist/cli/lib/script-pack-registry.js +397 -0
  12. package/dist/cli/lib/validation/index.js +2 -2
  13. package/dist/cli/lib/validation/primitives.js +4 -1
  14. package/dist/cli/script-packs/code-change-impact.js +178 -0
  15. package/dist/cli/script-packs/code-dependency-graph.js +181 -0
  16. package/dist/cli/script-packs/code-import-cycle.js +193 -0
  17. package/dist/cli/script-packs/docs-link-integrity.js +145 -0
  18. package/dist/cli/script-packs/repo-approval-gate.js +100 -0
  19. package/dist/cli/script-packs/repo-env-contract.js +156 -0
  20. package/dist/cli/script-packs/repo-git-ignore-audit.js +119 -0
  21. package/dist/cli/script-packs/repo-manifest-lock-drift.js +122 -0
  22. package/dist/cli/script-packs/repo-merge-conflict-scan.js +123 -0
  23. package/dist/cli/script-packs/repo-secret-risk-scan.js +147 -0
  24. package/dist/cli/script-packs/repo-skill-route-audit.js +86 -0
  25. package/dist/cli/script-packs/repo-version-source.js +92 -0
  26. package/dist/cli/script-packs/test-performance-report.js +247 -0
  27. package/dist/cli/script-packs/test-regression-selector.js +167 -0
  28. package/dist/core/change-impact.js +355 -0
  29. package/dist/core/change-surface-classification.js +198 -0
  30. package/dist/core/change-verification.js +32 -5
  31. package/dist/core/config-loading.js +121 -4
  32. package/dist/core/dependency-graph.js +490 -0
  33. package/dist/core/docs-link-integrity.js +443 -0
  34. package/dist/core/env-contract.js +450 -0
  35. package/dist/core/import-cycle.js +152 -0
  36. package/dist/core/line-endings.js +26 -13
  37. package/dist/core/public-json-contracts.js +167 -0
  38. package/dist/core/repo-approval-gate.js +116 -0
  39. package/dist/core/repo-git-ignore-audit.js +302 -0
  40. package/dist/core/repo-manifest-lock-drift.js +321 -0
  41. package/dist/core/repo-merge-conflict-scan.js +335 -0
  42. package/dist/core/repo-version-source.js +82 -0
  43. package/dist/core/route-outline.js +57 -5
  44. package/dist/core/script-pack-suggestions.js +97 -1
  45. package/dist/core/secret-risk-scan.js +440 -0
  46. package/dist/core/skill-route-audit.js +354 -0
  47. package/dist/core/test-performance-report.js +697 -0
  48. package/dist/core/test-regression-selector.js +335 -0
  49. package/package.json +1 -1
  50. package/schemas/README.md +54 -0
  51. package/schemas/change-impact-report.schema.json +184 -0
  52. package/schemas/commands.schema.json +12 -0
  53. package/schemas/dependency-graph-report.schema.json +149 -0
  54. package/schemas/env-contract-report.schema.json +203 -0
  55. package/schemas/import-cycle-report.schema.json +157 -0
  56. package/schemas/link-integrity-report.schema.json +176 -0
  57. package/schemas/repo-approval-gate-report.schema.json +115 -0
  58. package/schemas/repo-git-ignore-audit-report.schema.json +201 -0
  59. package/schemas/repo-manifest-lock-drift-report.schema.json +202 -0
  60. package/schemas/repo-merge-conflict-scan-report.schema.json +169 -0
  61. package/schemas/repo-version-source-report.schema.json +127 -0
  62. package/schemas/secret-risk-scan-report.schema.json +152 -0
  63. package/schemas/skill-route-audit-report.schema.json +144 -0
  64. package/schemas/test-performance-report.schema.json +319 -0
  65. package/schemas/test-regression-selector-report.schema.json +187 -0
  66. package/templates/default/i18n.toml +80 -26
  67. package/templates/default/locales/en/.mustflow/skills/INDEX.md +51 -9
  68. package/templates/default/locales/en/.mustflow/skills/api-access-control-review/SKILL.md +48 -27
  69. package/templates/default/locales/en/.mustflow/skills/api-failure-triage/SKILL.md +270 -0
  70. package/templates/default/locales/en/.mustflow/skills/architecture-deepening-review/SKILL.md +28 -11
  71. package/templates/default/locales/en/.mustflow/skills/astro-code-change/SKILL.md +71 -27
  72. package/templates/default/locales/en/.mustflow/skills/auth-flow-triage/SKILL.md +192 -0
  73. package/templates/default/locales/en/.mustflow/skills/auth-permission-change/SKILL.md +59 -13
  74. package/templates/default/locales/en/.mustflow/skills/backend-log-evidence-review/SKILL.md +14 -5
  75. package/templates/default/locales/en/.mustflow/skills/cache-integrity-review/SKILL.md +30 -15
  76. package/templates/default/locales/en/.mustflow/skills/change-blast-radius-review/SKILL.md +45 -32
  77. package/templates/default/locales/en/.mustflow/skills/ci-pipeline-triage/SKILL.md +200 -0
  78. package/templates/default/locales/en/.mustflow/skills/clarifying-question-gate/SKILL.md +87 -13
  79. package/templates/default/locales/en/.mustflow/skills/cross-agent-session-reference/SKILL.md +23 -8
  80. package/templates/default/locales/en/.mustflow/skills/dependency-upgrade-review/SKILL.md +3 -1
  81. package/templates/default/locales/en/.mustflow/skills/docker-runtime-triage/SKILL.md +191 -0
  82. package/templates/default/locales/en/.mustflow/skills/github-contribution-quality-gate/SKILL.md +48 -11
  83. package/templates/default/locales/en/.mustflow/skills/go-code-change/SKILL.md +18 -13
  84. package/templates/default/locales/en/.mustflow/skills/javascript-code-change/SKILL.md +15 -13
  85. package/templates/default/locales/en/.mustflow/skills/line-ending-hygiene/SKILL.md +18 -10
  86. package/templates/default/locales/en/.mustflow/skills/llm-hallucination-control-review/SKILL.md +4 -1
  87. package/templates/default/locales/en/.mustflow/skills/motion-system-contract-review/SKILL.md +155 -0
  88. package/templates/default/locales/en/.mustflow/skills/next-action-menu/SKILL.md +177 -0
  89. package/templates/default/locales/en/.mustflow/skills/node-code-change/SKILL.md +16 -14
  90. package/templates/default/locales/en/.mustflow/skills/observability-debuggability-review/SKILL.md +15 -7
  91. package/templates/default/locales/en/.mustflow/skills/payment-integrity-review/SKILL.md +59 -35
  92. package/templates/default/locales/en/.mustflow/skills/powershell-code-change/SKILL.md +16 -6
  93. package/templates/default/locales/en/.mustflow/skills/prompt-contract-quality-review/SKILL.md +4 -1
  94. package/templates/default/locales/en/.mustflow/skills/python-code-change/SKILL.md +19 -10
  95. package/templates/default/locales/en/.mustflow/skills/rag-pipeline-triage/SKILL.md +206 -0
  96. package/templates/default/locales/en/.mustflow/skills/routes.toml +69 -9
  97. package/templates/default/locales/en/.mustflow/skills/rust-code-change/SKILL.md +10 -4
  98. package/templates/default/locales/en/.mustflow/skills/search-index-integrity-review/SKILL.md +181 -0
  99. package/templates/default/locales/en/.mustflow/skills/security-privacy-review/SKILL.md +3 -1
  100. package/templates/default/locales/en/.mustflow/skills/service-boundary-architecture/SKILL.md +37 -23
  101. package/templates/default/locales/en/.mustflow/skills/test-suite-performance-review/SKILL.md +323 -0
  102. package/templates/default/locales/en/.mustflow/skills/typescript-code-change/SKILL.md +18 -10
  103. package/templates/default/locales/en/.mustflow/skills/vector-search-integrity-review/SKILL.md +209 -0
  104. package/templates/default/locales/en/.mustflow/skills/version-freshness-check/SKILL.md +16 -14
  105. package/templates/default/manifest.toml +71 -1
@@ -0,0 +1,440 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, lstatSync, readdirSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { ensureInside, ensureInsideWithoutSymlinks, readFileInsideWithoutSymlinks } from './safe-filesystem.js';
5
+ export const SECRET_RISK_SCAN_PACK_ID = 'repo';
6
+ export const SECRET_RISK_SCAN_SCRIPT_ID = 'secret-risk-scan';
7
+ export const SECRET_RISK_SCAN_SCRIPT_REF = `${SECRET_RISK_SCAN_PACK_ID}/${SECRET_RISK_SCAN_SCRIPT_ID}`;
8
+ const DEFAULT_MAX_FILE_BYTES = 256 * 1024;
9
+ const DEFAULT_MAX_FILES = 1000;
10
+ const DEFAULT_MAX_FINDINGS = 200;
11
+ const MAX_ISSUES = 50;
12
+ const SCAN_EXTENSIONS = [
13
+ '.ts',
14
+ '.tsx',
15
+ '.mts',
16
+ '.cts',
17
+ '.js',
18
+ '.jsx',
19
+ '.mjs',
20
+ '.cjs',
21
+ '.json',
22
+ '.toml',
23
+ '.yml',
24
+ '.yaml',
25
+ '.md',
26
+ '.mdx',
27
+ ];
28
+ const SECRET_FILE_NAMES = ['.env', '.env.local', '.env.production', '.env.development', '.dev.vars'];
29
+ const ENV_EXAMPLE_NAMES = [
30
+ '.env.example',
31
+ '.env.sample',
32
+ '.env.template',
33
+ '.env.defaults',
34
+ '.env.test.example',
35
+ '.env.local.example',
36
+ '.dev.vars.example',
37
+ ];
38
+ const IGNORED_DIRECTORIES = [
39
+ '.git',
40
+ '.mustflow/cache',
41
+ '.mustflow/state',
42
+ 'node_modules',
43
+ 'dist',
44
+ 'build',
45
+ 'coverage',
46
+ '.next',
47
+ '.turbo',
48
+ ];
49
+ const ERROR_CODES = new Set([
50
+ 'secret_risk_path_outside_root',
51
+ 'secret_risk_unreadable_path',
52
+ ]);
53
+ function normalizeRelativePath(value) {
54
+ return value.replace(/\\/gu, '/').replace(/^\.\/+/u, '') || '.';
55
+ }
56
+ function fingerprint(value) {
57
+ return `sha256:${createHash('sha256').update(value).digest('hex').slice(0, 16)}`;
58
+ }
59
+ function sha256Tagged(value) {
60
+ return `sha256:${createHash('sha256').update(value).digest('hex')}`;
61
+ }
62
+ function pushIssue(issues, issue) {
63
+ if (issues.length < MAX_ISSUES) {
64
+ issues.push(issue);
65
+ }
66
+ }
67
+ function makeFinding(code, severity, pathValue, message, details = {}) {
68
+ return { code, severity, path: pathValue, message, ...details };
69
+ }
70
+ function isIgnoredDirectory(relativePath) {
71
+ const normalized = normalizeRelativePath(relativePath);
72
+ return IGNORED_DIRECTORIES.some((directory) => normalized === directory || normalized.startsWith(`${directory}/`));
73
+ }
74
+ function isSecretFile(relativePath) {
75
+ return SECRET_FILE_NAMES.includes(path.basename(relativePath).toLowerCase());
76
+ }
77
+ function isEnvExampleFile(relativePath) {
78
+ return ENV_EXAMPLE_NAMES.includes(path.basename(relativePath).toLowerCase());
79
+ }
80
+ function surfaceForPath(relativePath) {
81
+ if (isEnvExampleFile(relativePath)) {
82
+ return 'example';
83
+ }
84
+ const normalized = normalizeRelativePath(relativePath);
85
+ const extension = path.extname(normalized).toLowerCase();
86
+ if (!SCAN_EXTENSIONS.includes(extension)) {
87
+ return null;
88
+ }
89
+ if (normalized.startsWith('.github/workflows/') || ['.yml', '.yaml'].includes(extension)) {
90
+ return 'ci';
91
+ }
92
+ if (['.md', '.mdx'].includes(extension)) {
93
+ return 'docs';
94
+ }
95
+ if (['.json', '.toml'].includes(extension) || normalized.startsWith('.mustflow/config/')) {
96
+ return 'config';
97
+ }
98
+ return 'code';
99
+ }
100
+ function normalizeTargetPath(projectRoot, targetPath) {
101
+ const absolutePath = path.resolve(process.cwd(), targetPath);
102
+ ensureInside(projectRoot, absolutePath);
103
+ return {
104
+ absolutePath,
105
+ relativePath: normalizeRelativePath(path.relative(projectRoot, absolutePath)),
106
+ };
107
+ }
108
+ function targetKind(absolutePath) {
109
+ if (!existsSync(absolutePath)) {
110
+ return { exists: false, kind: 'missing' };
111
+ }
112
+ const stats = lstatSync(absolutePath);
113
+ if (stats.isFile()) {
114
+ return { exists: true, kind: 'file' };
115
+ }
116
+ if (stats.isDirectory()) {
117
+ return { exists: true, kind: 'directory' };
118
+ }
119
+ return { exists: true, kind: 'other' };
120
+ }
121
+ function addCandidate(candidates, findings, issues, policy, candidate) {
122
+ if (candidates.has(candidate.relativePath)) {
123
+ return;
124
+ }
125
+ if (candidates.size >= policy.max_files) {
126
+ if (!findings.some((finding) => finding.code === 'secret_risk_max_files_exceeded')) {
127
+ const message = `Secret-risk scan matched more than ${policy.max_files} files; remaining files were skipped.`;
128
+ pushIssue(issues, message);
129
+ findings.push(makeFinding('secret_risk_max_files_exceeded', 'medium', candidate.relativePath, message));
130
+ }
131
+ return;
132
+ }
133
+ candidates.set(candidate.relativePath, candidate);
134
+ }
135
+ function collectFilesFromDirectory(projectRoot, absoluteDirectory, candidates, findings, issues, policy) {
136
+ const relativeDirectory = normalizeRelativePath(path.relative(projectRoot, absoluteDirectory));
137
+ if (isIgnoredDirectory(relativeDirectory)) {
138
+ return;
139
+ }
140
+ let entries;
141
+ try {
142
+ ensureInsideWithoutSymlinks(projectRoot, absoluteDirectory);
143
+ entries = readdirSync(absoluteDirectory, { withFileTypes: true });
144
+ }
145
+ catch (error) {
146
+ const message = error instanceof Error ? error.message : String(error);
147
+ pushIssue(issues, `${relativeDirectory}: ${message}`);
148
+ findings.push(makeFinding('secret_risk_unreadable_path', 'high', relativeDirectory, message));
149
+ return;
150
+ }
151
+ for (const entry of entries) {
152
+ const absoluteEntry = path.join(absoluteDirectory, entry.name);
153
+ const relativeEntry = normalizeRelativePath(path.relative(projectRoot, absoluteEntry));
154
+ if (entry.isDirectory()) {
155
+ collectFilesFromDirectory(projectRoot, absoluteEntry, candidates, findings, issues, policy);
156
+ continue;
157
+ }
158
+ if (!entry.isFile()) {
159
+ continue;
160
+ }
161
+ const surface = surfaceForPath(relativeEntry);
162
+ if (surface || isSecretFile(relativeEntry)) {
163
+ addCandidate(candidates, findings, issues, policy, { absolutePath: absoluteEntry, relativePath: relativeEntry, surface: surface ?? 'config' });
164
+ }
165
+ }
166
+ }
167
+ function lineNumberAtIndex(text, index) {
168
+ let line = 1;
169
+ let offset = 0;
170
+ while (offset < index) {
171
+ if (text.charCodeAt(offset) === 10) {
172
+ line += 1;
173
+ }
174
+ offset += 1;
175
+ }
176
+ return line;
177
+ }
178
+ function looksLikePlaceholder(value) {
179
+ const normalized = value.trim().replace(/^['"`]|['"`]$/gu, '').toLowerCase();
180
+ if (normalized.length === 0) {
181
+ return true;
182
+ }
183
+ if (/^(?:example|sample|dummy|fake|test|changeme|change_me|placeholder|todo|null|undefined|your[_-].*|xxx+|\*+)$/u.test(normalized)) {
184
+ return true;
185
+ }
186
+ return ((normalized.startsWith('<') && normalized.endsWith('>')) ||
187
+ (normalized.startsWith('${') && normalized.endsWith('}')) ||
188
+ (normalized.startsWith('{{') && normalized.endsWith('}}')));
189
+ }
190
+ function entropyScore(value) {
191
+ const normalized = value.trim();
192
+ if (normalized.length === 0) {
193
+ return 0;
194
+ }
195
+ const counts = new Map();
196
+ for (const character of normalized) {
197
+ counts.set(character, (counts.get(character) ?? 0) + 1);
198
+ }
199
+ let entropy = 0;
200
+ for (const count of counts.values()) {
201
+ const probability = count / normalized.length;
202
+ entropy -= probability * Math.log2(probability);
203
+ }
204
+ return entropy;
205
+ }
206
+ function isSecretLikeName(name) {
207
+ return /(?:SECRET|TOKEN|PASSWORD|PASS|PRIVATE|CREDENTIAL|API[_-]?KEY|ACCESS[_-]?KEY|AUTH|SESSION|SIGNING|WEBHOOK)/iu.test(name);
208
+ }
209
+ function looksLikeSecretValue(value) {
210
+ const cleaned = value.trim().replace(/^['"`]|['"`]$/gu, '');
211
+ if (looksLikePlaceholder(cleaned)) {
212
+ return false;
213
+ }
214
+ if (/^(?:sk-[A-Za-z0-9_-]{20,}|ghp_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{20,}|AKIA[0-9A-Z]{16})$/u.test(cleaned)) {
215
+ return true;
216
+ }
217
+ return cleaned.length >= 24 && entropyScore(cleaned) >= 3.5 && /[A-Za-z]/u.test(cleaned) && /\d/u.test(cleaned);
218
+ }
219
+ function addBoundedFinding(findings, issues, policy, finding) {
220
+ if (findings.length >= policy.max_findings) {
221
+ if (!findings.some((entry) => entry.code === 'secret_risk_max_findings_exceeded')) {
222
+ const message = `Secret-risk scan found more than ${policy.max_findings} findings; remaining findings were skipped.`;
223
+ pushIssue(issues, message);
224
+ findings.push(makeFinding('secret_risk_max_findings_exceeded', 'medium', finding.path, message));
225
+ }
226
+ return;
227
+ }
228
+ findings.push(finding);
229
+ }
230
+ function scanPattern(text, candidate, findings, issues, policy, pattern, create) {
231
+ for (const match of text.matchAll(pattern)) {
232
+ const line = lineNumberAtIndex(text, match.index ?? 0);
233
+ const finding = create(match, line);
234
+ if (finding) {
235
+ addBoundedFinding(findings, issues, policy, finding);
236
+ }
237
+ }
238
+ }
239
+ function scanPrivateKeyBlocks(text, candidate, findings, issues, policy) {
240
+ scanPattern(text, candidate, findings, issues, policy, /-----BEGIN [A-Z ]*PRIVATE KEY-----/gu, (match, line) => makeFinding('secret_risk_private_key_block', 'critical', candidate.relativePath, 'Private key block marker found.', {
241
+ line,
242
+ detector: 'private_key_block',
243
+ fingerprint: fingerprint(match[0]),
244
+ }));
245
+ }
246
+ function scanBearerTokens(text, candidate, findings, issues, policy) {
247
+ scanPattern(text, candidate, findings, issues, policy, /\bBearer\s+(?<value>[A-Za-z0-9._~+/=-]{24,})\b/gu, (match, line) => {
248
+ const value = match.groups?.value ?? '';
249
+ if (!looksLikeSecretValue(value)) {
250
+ return null;
251
+ }
252
+ return makeFinding('secret_risk_bearer_token', 'high', candidate.relativePath, 'Bearer token-like value found.', {
253
+ line,
254
+ detector: 'bearer_token',
255
+ fingerprint: fingerprint(value),
256
+ });
257
+ });
258
+ }
259
+ function scanProviderTokens(text, candidate, findings, issues, policy) {
260
+ const pattern = /\b(?<value>sk-[A-Za-z0-9_-]{20,}|ghp_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{20,}|AKIA[0-9A-Z]{16})\b/gu;
261
+ scanPattern(text, candidate, findings, issues, policy, pattern, (match, line) => {
262
+ const value = match.groups?.value ?? '';
263
+ if (looksLikePlaceholder(value)) {
264
+ return null;
265
+ }
266
+ return makeFinding('secret_risk_provider_token', 'high', candidate.relativePath, 'Provider token-like value found.', {
267
+ line,
268
+ detector: 'provider_token',
269
+ fingerprint: fingerprint(value),
270
+ });
271
+ });
272
+ }
273
+ function scanAssignments(text, candidate, findings, issues, policy) {
274
+ const pattern = /(?:const|let|var|export\s+const|export\s+let|export\s+var)?\s*(?<name>[A-Za-z_][A-Za-z0-9_.-]*(?:SECRET|TOKEN|PASSWORD|PASS|PRIVATE|CREDENTIAL|API[_-]?KEY|ACCESS[_-]?KEY|AUTH|SESSION|SIGNING|WEBHOOK)[A-Za-z0-9_.-]*)\s*[:=]\s*["'`](?<value>[^"'`\r\n]{8,})["'`]/giu;
275
+ scanPattern(text, candidate, findings, issues, policy, pattern, (match, line) => {
276
+ const name = match.groups?.name ?? '';
277
+ const value = match.groups?.value ?? '';
278
+ if (!isSecretLikeName(name) || !looksLikeSecretValue(value)) {
279
+ return null;
280
+ }
281
+ return makeFinding('secret_risk_generic_assignment', 'high', candidate.relativePath, 'Secret-like assignment found.', {
282
+ line,
283
+ detector: 'generic_assignment',
284
+ fingerprint: fingerprint(`${name}:${value}`),
285
+ });
286
+ });
287
+ }
288
+ function scanEnvExample(text, candidate, findings, issues, policy) {
289
+ const lines = text.split(/\r?\n/u);
290
+ for (const [index, line] of lines.entries()) {
291
+ const match = /^\s*(?:export\s+)?(?<name>[A-Za-z_][A-Za-z0-9_.-]*)\s*=\s*(?<value>.+?)\s*$/u.exec(line);
292
+ if (!match?.groups) {
293
+ continue;
294
+ }
295
+ const { name, value } = match.groups;
296
+ if (!isSecretLikeName(name) || !looksLikeSecretValue(value)) {
297
+ continue;
298
+ }
299
+ addBoundedFinding(findings, issues, policy, makeFinding('secret_risk_realistic_env_example', 'medium', candidate.relativePath, 'Env example contains a realistic secret-like value.', {
300
+ line: index + 1,
301
+ detector: 'realistic_env_example',
302
+ fingerprint: fingerprint(`${name}:${value}`),
303
+ }));
304
+ }
305
+ }
306
+ function inspectCandidate(projectRoot, candidate, policy, findings, issues) {
307
+ if (isSecretFile(candidate.relativePath)) {
308
+ addBoundedFinding(findings, issues, policy, makeFinding('secret_risk_secret_file_skipped', 'low', candidate.relativePath, `${candidate.relativePath} was skipped to avoid reading real secret values.`, {
309
+ detector: 'secret_file_skipped',
310
+ }));
311
+ return;
312
+ }
313
+ let text;
314
+ try {
315
+ text = readFileInsideWithoutSymlinks(projectRoot, candidate.absolutePath, { maxBytes: policy.max_file_bytes }).toString('utf8');
316
+ }
317
+ catch (error) {
318
+ const message = error instanceof Error ? error.message : String(error);
319
+ pushIssue(issues, `${candidate.relativePath}: ${message}`);
320
+ findings.push(makeFinding('secret_risk_unreadable_path', 'high', candidate.relativePath, message));
321
+ return;
322
+ }
323
+ scanPrivateKeyBlocks(text, candidate, findings, issues, policy);
324
+ scanBearerTokens(text, candidate, findings, issues, policy);
325
+ scanProviderTokens(text, candidate, findings, issues, policy);
326
+ scanAssignments(text, candidate, findings, issues, policy);
327
+ if (candidate.surface === 'example') {
328
+ scanEnvExample(text, candidate, findings, issues, policy);
329
+ }
330
+ }
331
+ function secretRiskStatus(findings) {
332
+ if (findings.some((finding) => ERROR_CODES.has(finding.code))) {
333
+ return 'error';
334
+ }
335
+ if (findings.some((finding) => ['medium', 'high', 'critical'].includes(finding.severity))) {
336
+ return 'failed';
337
+ }
338
+ return 'passed';
339
+ }
340
+ function summarizeSecretRisk(targets, fileCount, findings) {
341
+ return {
342
+ target_count: targets.length,
343
+ file_count: fileCount,
344
+ finding_count: findings.length,
345
+ skipped_secret_file_count: findings.filter((finding) => finding.code === 'secret_risk_secret_file_skipped').length,
346
+ high_or_critical_count: findings.filter((finding) => ['high', 'critical'].includes(finding.severity)).length,
347
+ };
348
+ }
349
+ function createInputHash(policy, targets, findings, issues) {
350
+ return sha256Tagged(JSON.stringify({
351
+ policy,
352
+ targets,
353
+ findings: findings.map((finding) => ({
354
+ code: finding.code,
355
+ path: finding.path,
356
+ line: finding.line,
357
+ detector: finding.detector,
358
+ fingerprint: finding.fingerprint,
359
+ })),
360
+ issues,
361
+ }));
362
+ }
363
+ export function inspectSecretRiskScan(projectRoot, options = {}) {
364
+ const root = path.resolve(projectRoot);
365
+ const policy = {
366
+ max_file_bytes: options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES,
367
+ max_files: options.maxFiles ?? DEFAULT_MAX_FILES,
368
+ max_findings: options.maxFindings ?? DEFAULT_MAX_FINDINGS,
369
+ extensions: [...SCAN_EXTENSIONS],
370
+ skipped_secret_names: [...SECRET_FILE_NAMES],
371
+ ignored_directories: [...IGNORED_DIRECTORIES],
372
+ };
373
+ const targetInputs = options.paths && options.paths.length > 0 ? options.paths : ['.'];
374
+ const targets = [];
375
+ const candidates = new Map();
376
+ const findings = [];
377
+ const issues = [];
378
+ for (const targetPath of targetInputs) {
379
+ let absolutePath;
380
+ let relativePath;
381
+ try {
382
+ const normalized = normalizeTargetPath(root, targetPath);
383
+ absolutePath = normalized.absolutePath;
384
+ relativePath = normalized.relativePath;
385
+ ensureInsideWithoutSymlinks(root, absolutePath, { allowMissingLeaf: true });
386
+ }
387
+ catch (error) {
388
+ const message = error instanceof Error ? error.message : String(error);
389
+ pushIssue(issues, message);
390
+ targets.push({ input: targetPath, path: targetPath, exists: null, kind: 'unknown' });
391
+ findings.push(makeFinding('secret_risk_path_outside_root', 'high', targetPath, message));
392
+ continue;
393
+ }
394
+ let existence;
395
+ try {
396
+ existence = targetKind(absolutePath);
397
+ }
398
+ catch (error) {
399
+ const message = error instanceof Error ? error.message : String(error);
400
+ pushIssue(issues, `${relativePath}: ${message}`);
401
+ targets.push({ input: targetPath, path: relativePath, exists: null, kind: 'unknown' });
402
+ findings.push(makeFinding('secret_risk_unreadable_path', 'high', relativePath, message));
403
+ continue;
404
+ }
405
+ targets.push({ input: targetPath, path: relativePath, exists: existence.exists, kind: existence.kind });
406
+ if (existence.kind === 'file') {
407
+ const surface = surfaceForPath(relativePath);
408
+ if (surface || isSecretFile(relativePath)) {
409
+ addCandidate(candidates, findings, issues, policy, { absolutePath, relativePath, surface: surface ?? 'config' });
410
+ }
411
+ }
412
+ else if (existence.kind === 'directory') {
413
+ collectFilesFromDirectory(root, absolutePath, candidates, findings, issues, policy);
414
+ }
415
+ }
416
+ for (const candidate of candidates.values()) {
417
+ inspectCandidate(root, candidate, policy, findings, issues);
418
+ }
419
+ const status = secretRiskStatus(findings);
420
+ const truncated = findings.some((finding) => ['secret_risk_max_files_exceeded', 'secret_risk_max_findings_exceeded'].includes(finding.code));
421
+ const summary = summarizeSecretRisk(targets, candidates.size, findings);
422
+ return {
423
+ schema_version: '1',
424
+ command: 'script-pack',
425
+ pack_id: SECRET_RISK_SCAN_PACK_ID,
426
+ script_id: SECRET_RISK_SCAN_SCRIPT_ID,
427
+ script_ref: SECRET_RISK_SCAN_SCRIPT_REF,
428
+ action: 'scan',
429
+ status,
430
+ ok: status === 'passed',
431
+ mustflow_root: root,
432
+ policy,
433
+ input_hash: createInputHash(policy, targets, findings, issues),
434
+ targets,
435
+ summary,
436
+ truncated,
437
+ findings,
438
+ issues,
439
+ };
440
+ }