mustflow 2.75.2 → 2.85.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -3
- package/dist/cli/commands/docs.js +86 -2
- package/dist/cli/commands/script-pack.js +9 -0
- package/dist/cli/i18n/en.js +180 -2
- package/dist/cli/i18n/es.js +180 -2
- package/dist/cli/i18n/fr.js +180 -2
- package/dist/cli/i18n/hi.js +180 -2
- package/dist/cli/i18n/ko.js +180 -2
- package/dist/cli/i18n/zh.js +180 -2
- package/dist/cli/lib/repo-map.js +27 -6
- package/dist/cli/lib/run-root-trust.js +15 -1
- package/dist/cli/lib/script-pack-registry.js +275 -6
- package/dist/cli/lib/validation/index.js +2 -2
- package/dist/cli/lib/validation/primitives.js +4 -1
- package/dist/cli/script-packs/code-change-impact.js +172 -0
- package/dist/cli/script-packs/code-dependency-graph.js +181 -0
- package/dist/cli/script-packs/code-export-diff.js +160 -0
- package/dist/cli/script-packs/code-outline.js +33 -5
- package/dist/cli/script-packs/code-route-outline.js +155 -0
- package/dist/cli/script-packs/docs-reference-drift.js +150 -0
- package/dist/cli/script-packs/repo-config-chain.js +163 -0
- package/dist/cli/script-packs/repo-env-contract.js +156 -0
- package/dist/cli/script-packs/repo-related-files.js +161 -0
- package/dist/cli/script-packs/repo-secret-risk-scan.js +147 -0
- package/dist/core/change-impact.js +383 -0
- package/dist/core/change-verification.js +32 -5
- package/dist/core/code-outline.js +460 -79
- package/dist/core/config-chain.js +595 -0
- package/dist/core/config-loading.js +121 -4
- package/dist/core/dependency-graph.js +490 -0
- package/dist/core/env-contract.js +450 -0
- package/dist/core/export-diff.js +359 -0
- package/dist/core/line-endings.js +26 -13
- package/dist/core/public-json-contracts.js +126 -0
- package/dist/core/reference-drift.js +388 -0
- package/dist/core/related-files.js +493 -0
- package/dist/core/route-outline.js +964 -0
- package/dist/core/script-pack-suggestions.js +131 -5
- package/dist/core/secret-risk-scan.js +440 -0
- package/dist/core/source-anchors.js +13 -1
- package/package.json +1 -1
- package/schemas/README.md +44 -6
- package/schemas/change-impact-report.schema.json +150 -0
- package/schemas/code-outline-report.schema.json +1 -1
- package/schemas/code-symbol-read-report.schema.json +64 -4
- package/schemas/commands.schema.json +12 -0
- package/schemas/config-chain-report.schema.json +187 -0
- package/schemas/dependency-graph-report.schema.json +149 -0
- package/schemas/env-contract-report.schema.json +203 -0
- package/schemas/export-diff-report.schema.json +220 -0
- package/schemas/reference-drift-report.schema.json +166 -0
- package/schemas/related-files-report.schema.json +145 -0
- package/schemas/route-outline-report.schema.json +200 -0
- package/schemas/secret-risk-scan-report.schema.json +152 -0
- package/templates/default/common/.mustflow/config/commands.toml +21 -0
- package/templates/default/i18n.toml +21 -9
- package/templates/default/locales/en/.mustflow/docs/agent-workflow.md +1 -1
- package/templates/default/locales/en/.mustflow/skills/INDEX.md +8 -2
- package/templates/default/locales/en/.mustflow/skills/architecture-deepening-review/SKILL.md +28 -11
- package/templates/default/locales/en/.mustflow/skills/astro-code-change/SKILL.md +71 -27
- package/templates/default/locales/en/.mustflow/skills/cross-agent-session-reference/SKILL.md +146 -0
- package/templates/default/locales/en/.mustflow/skills/dependency-upgrade-review/SKILL.md +3 -1
- package/templates/default/locales/en/.mustflow/skills/github-contribution-quality-gate/SKILL.md +48 -11
- package/templates/default/locales/en/.mustflow/skills/javascript-code-change/SKILL.md +15 -13
- package/templates/default/locales/en/.mustflow/skills/node-code-change/SKILL.md +16 -14
- package/templates/default/locales/en/.mustflow/skills/routes.toml +21 -9
- package/templates/default/locales/en/.mustflow/skills/security-privacy-review/SKILL.md +3 -1
- package/templates/default/locales/en/.mustflow/skills/test-suite-performance-review/SKILL.md +314 -0
- package/templates/default/locales/en/.mustflow/skills/typescript-code-change/SKILL.md +13 -10
- package/templates/default/manifest.toml +15 -1
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
const CODE_NAVIGATION_SCRIPT_REFS = new Set([
|
|
4
|
+
'code/outline',
|
|
5
|
+
'code/dependency-graph',
|
|
6
|
+
'code/symbol-read',
|
|
7
|
+
'code/route-outline',
|
|
8
|
+
'code/export-diff',
|
|
9
|
+
'repo/related-files',
|
|
10
|
+
]);
|
|
11
|
+
const CONFIG_CHAIN_SURFACES = new Set(['config', 'package', 'source', 'test']);
|
|
12
|
+
const CONFIG_FILE_PATTERN = /(?:^|\/)(?:\.env\.(?:example|sample|template|defaults)|\.dev\.vars\.example|tsconfig(?:\..*)?\.json|eslint\.config\.[cm]?[jt]s|\.eslintrc(?:\.json)?|\.prettierrc(?:\.json)?|prettier\.config\.[cm]?[jt]s|vite\.config\.[cm]?[jt]s|vitest\.config\.[cm]?[jt]s|tailwind\.config\.[cm]?[jt]s|jest\.config\.[cm]?[jt]s|playwright\.config\.[cm]?[jt]s|astro\.config\.mjs|svelte\.config\.js)$/u;
|
|
3
13
|
export function isScriptPackSuggestionPhase(value) {
|
|
4
14
|
return ['before_change', 'during_change', 'after_change', 'review'].includes(value);
|
|
5
15
|
}
|
|
@@ -12,6 +22,9 @@ function uniqueSortedPhases(values) {
|
|
|
12
22
|
function uniqueSortedSurfaces(values) {
|
|
13
23
|
return uniqueSortedStrings(values);
|
|
14
24
|
}
|
|
25
|
+
function quoteCliArg(value) {
|
|
26
|
+
return /^[A-Za-z0-9_./:@=-]+$/u.test(value) ? value : JSON.stringify(value);
|
|
27
|
+
}
|
|
15
28
|
function normalizeReportPath(mustflowRoot, value) {
|
|
16
29
|
const absolute = path.resolve(mustflowRoot, value);
|
|
17
30
|
const relative = path.relative(mustflowRoot, absolute);
|
|
@@ -31,7 +44,7 @@ export function classifyScriptPackPathSurface(relativePath) {
|
|
|
31
44
|
normalized.startsWith('.mustflow/state/')) {
|
|
32
45
|
surfaces.push('generated');
|
|
33
46
|
}
|
|
34
|
-
if (normalized.startsWith('.mustflow/config/') || normalized.startsWith('config/')) {
|
|
47
|
+
if (normalized.startsWith('.mustflow/config/') || normalized.startsWith('config/') || CONFIG_FILE_PATTERN.test(normalized)) {
|
|
35
48
|
surfaces.push('config');
|
|
36
49
|
}
|
|
37
50
|
if (normalized.startsWith('.mustflow/skills/') || normalized.includes('/.mustflow/skills/')) {
|
|
@@ -59,7 +72,8 @@ export function classifyScriptPackPathSurface(relativePath) {
|
|
|
59
72
|
normalized.startsWith('.github/workflows/')) {
|
|
60
73
|
surfaces.push('package');
|
|
61
74
|
}
|
|
62
|
-
if (normalized.startsWith('src/') ||
|
|
75
|
+
if (normalized.startsWith('src/') ||
|
|
76
|
+
/\.(?:astro|cjs|go|js|jsx|mjs|py|rs|svelte|ts|tsx)$/u.test(normalized)) {
|
|
63
77
|
surfaces.push('source');
|
|
64
78
|
}
|
|
65
79
|
return surfaces.length > 0 ? uniqueSortedSurfaces(surfaces) : ['unknown'];
|
|
@@ -108,7 +122,7 @@ function surfacesForScript(script) {
|
|
|
108
122
|
addIf('generated', /generated|protected|vendor|cache|boundary/u);
|
|
109
123
|
addIf('config', /config|command/u);
|
|
110
124
|
addIf('package', /package|release/u);
|
|
111
|
-
addIf('source', /code|source|
|
|
125
|
+
addIf('source', /code|source|symbol/u);
|
|
112
126
|
return uniqueSortedSurfaces(surfaces);
|
|
113
127
|
}
|
|
114
128
|
function confidenceForScore(score) {
|
|
@@ -120,14 +134,118 @@ function confidenceForScore(score) {
|
|
|
120
134
|
}
|
|
121
135
|
return 'low';
|
|
122
136
|
}
|
|
137
|
+
function pathsWithSurface(analyzedPaths, surface) {
|
|
138
|
+
return analyzedPaths.filter((entry) => entry.surfaces.includes(surface)).map((entry) => entry.path);
|
|
139
|
+
}
|
|
140
|
+
function hasPathWithSurface(analyzedPaths, surface) {
|
|
141
|
+
return analyzedPaths.some((entry) => entry.surfaces.includes(surface));
|
|
142
|
+
}
|
|
143
|
+
function firstAvailablePath(analyzedPaths, preferredSurfaces) {
|
|
144
|
+
for (const surface of preferredSurfaces) {
|
|
145
|
+
const [candidate] = pathsWithSurface(analyzedPaths, surface);
|
|
146
|
+
if (candidate) {
|
|
147
|
+
return candidate;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return analyzedPaths[0]?.path ?? null;
|
|
151
|
+
}
|
|
152
|
+
function createConcretePathHint(commandPrefix, paths, fallbackUsage) {
|
|
153
|
+
if (paths.length === 0) {
|
|
154
|
+
return fallbackUsage;
|
|
155
|
+
}
|
|
156
|
+
return `${commandPrefix} ${paths.map(quoteCliArg).join(' ')} --json`;
|
|
157
|
+
}
|
|
158
|
+
function createRunHint(script, analyzedPaths) {
|
|
159
|
+
if (script.ref === 'code/outline') {
|
|
160
|
+
const sourcePaths = pathsWithSurface(analyzedPaths, 'source');
|
|
161
|
+
return createConcretePathHint('mf script-pack run code/outline scan', sourcePaths, script.usage);
|
|
162
|
+
}
|
|
163
|
+
if (script.ref === 'code/symbol-read') {
|
|
164
|
+
const sourcePath = firstAvailablePath(analyzedPaths, ['source']);
|
|
165
|
+
if (sourcePath) {
|
|
166
|
+
return `After code/outline returns a symbol line or anchor, run: mf script-pack run code/symbol-read read ${quoteCliArg(sourcePath)} --start-line <line> --json`;
|
|
167
|
+
}
|
|
168
|
+
return 'After code/outline returns a source anchor, run: mf script-pack run code/symbol-read read --anchor <anchor-id> --json';
|
|
169
|
+
}
|
|
170
|
+
if (script.ref === 'code/dependency-graph') {
|
|
171
|
+
const sourcePaths = pathsWithSurface(analyzedPaths, 'source');
|
|
172
|
+
return createConcretePathHint('mf script-pack run code/dependency-graph scan', sourcePaths, script.usage);
|
|
173
|
+
}
|
|
174
|
+
if (script.ref === 'code/change-impact') {
|
|
175
|
+
return 'mf script-pack run code/change-impact analyze --base HEAD --json';
|
|
176
|
+
}
|
|
177
|
+
if (script.ref === 'code/route-outline') {
|
|
178
|
+
const sourcePaths = pathsWithSurface(analyzedPaths, 'source');
|
|
179
|
+
return createConcretePathHint('mf script-pack run code/route-outline scan', sourcePaths, script.usage);
|
|
180
|
+
}
|
|
181
|
+
if (script.ref === 'code/export-diff') {
|
|
182
|
+
const sourcePaths = pathsWithSurface(analyzedPaths, 'source');
|
|
183
|
+
const pathPart = sourcePaths.length > 0 ? ` ${sourcePaths.map(quoteCliArg).join(' ')}` : '';
|
|
184
|
+
return `mf script-pack run code/export-diff compare${pathPart} --base HEAD --json`;
|
|
185
|
+
}
|
|
186
|
+
if (script.ref === 'core/text-budget') {
|
|
187
|
+
const packageJson = analyzedPaths.find((entry) => entry.path === 'package.json');
|
|
188
|
+
if (packageJson) {
|
|
189
|
+
return 'mf script-pack run core/text-budget check package.json --json-pointer /description --max 80 --json';
|
|
190
|
+
}
|
|
191
|
+
const textPaths = analyzedPaths
|
|
192
|
+
.filter((entry) => entry.surfaces.some((surface) => surface === 'docs' || surface === 'package' || surface === 'schema'))
|
|
193
|
+
.map((entry) => entry.path);
|
|
194
|
+
return createConcretePathHint('mf script-pack run core/text-budget check', textPaths, script.usage);
|
|
195
|
+
}
|
|
196
|
+
if (script.ref === 'docs/reference-drift') {
|
|
197
|
+
const docsPaths = analyzedPaths
|
|
198
|
+
.filter((entry) => entry.surfaces.some((surface) => surface === 'docs' || surface === 'schema' || surface === 'package'))
|
|
199
|
+
.map((entry) => entry.path);
|
|
200
|
+
return createConcretePathHint('mf script-pack run docs/reference-drift check', docsPaths, script.usage);
|
|
201
|
+
}
|
|
202
|
+
if (script.ref === 'repo/generated-boundary') {
|
|
203
|
+
return createConcretePathHint('mf script-pack run repo/generated-boundary check', analyzedPaths.map((entry) => entry.path), script.usage);
|
|
204
|
+
}
|
|
205
|
+
if (script.ref === 'repo/config-chain') {
|
|
206
|
+
const configPaths = analyzedPaths
|
|
207
|
+
.filter((entry) => entry.surfaces.some((surface) => CONFIG_CHAIN_SURFACES.has(surface)))
|
|
208
|
+
.map((entry) => entry.path);
|
|
209
|
+
return createConcretePathHint('mf script-pack run repo/config-chain inspect', configPaths, script.usage);
|
|
210
|
+
}
|
|
211
|
+
if (script.ref === 'repo/env-contract') {
|
|
212
|
+
const envPaths = analyzedPaths
|
|
213
|
+
.filter((entry) => entry.surfaces.some((surface) => surface === 'config' || surface === 'source' || surface === 'docs' || surface === 'package'))
|
|
214
|
+
.map((entry) => entry.path);
|
|
215
|
+
return createConcretePathHint('mf script-pack run repo/env-contract scan', envPaths, script.usage);
|
|
216
|
+
}
|
|
217
|
+
if (script.ref === 'repo/secret-risk-scan') {
|
|
218
|
+
const secretRiskPaths = analyzedPaths
|
|
219
|
+
.filter((entry) => entry.surfaces.some((surface) => surface === 'config' || surface === 'source' || surface === 'docs' || surface === 'package' || surface === 'test'))
|
|
220
|
+
.map((entry) => entry.path);
|
|
221
|
+
return createConcretePathHint('mf script-pack run repo/secret-risk-scan scan', secretRiskPaths, script.usage);
|
|
222
|
+
}
|
|
223
|
+
if (script.ref === 'repo/related-files') {
|
|
224
|
+
const relatedPaths = analyzedPaths
|
|
225
|
+
.filter((entry) => entry.surfaces.some((surface) => surface === 'source' || surface === 'test'))
|
|
226
|
+
.map((entry) => entry.path);
|
|
227
|
+
return createConcretePathHint('mf script-pack run repo/related-files map', relatedPaths, script.usage);
|
|
228
|
+
}
|
|
229
|
+
return script.usage;
|
|
230
|
+
}
|
|
123
231
|
export function createScriptPackSuggestionReport(mustflowRoot, options) {
|
|
124
232
|
const issues = [];
|
|
125
233
|
const changedPaths = options.changed ? readChangedPaths(mustflowRoot, issues) : [];
|
|
126
234
|
const inputPaths = uniqueSortedStrings([...options.paths, ...changedPaths].map((value) => normalizeReportPath(mustflowRoot, value)));
|
|
127
235
|
const analyzedPaths = inputPaths.map((entry) => ({ path: entry, surfaces: classifyScriptPackPathSurface(entry) }));
|
|
128
236
|
const requestedSurfaces = new Set(analyzedPaths.flatMap((entry) => entry.surfaces));
|
|
237
|
+
const hasSourcePath = hasPathWithSurface(analyzedPaths, 'source');
|
|
129
238
|
const suggestions = options.scripts
|
|
130
239
|
.map((script) => {
|
|
240
|
+
if (CODE_NAVIGATION_SCRIPT_REFS.has(script.ref) && inputPaths.length > 0 && !hasSourcePath) {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
if (script.ref === 'repo/config-chain' && inputPaths.length > 0) {
|
|
244
|
+
const hasConfigChainSurface = analyzedPaths.some((entry) => entry.surfaces.some((surface) => CONFIG_CHAIN_SURFACES.has(surface)));
|
|
245
|
+
if (!hasConfigChainSurface) {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
131
249
|
let score = 0;
|
|
132
250
|
const reasons = [];
|
|
133
251
|
const matchedPhases = options.phases.filter((phase) => script.phases.includes(phase));
|
|
@@ -150,10 +268,18 @@ export function createScriptPackSuggestionReport(mustflowRoot, options) {
|
|
|
150
268
|
score += 1;
|
|
151
269
|
reasons.push('Accepts explicit path inputs.');
|
|
152
270
|
}
|
|
153
|
-
if (script.
|
|
271
|
+
if (script.ref === 'code/symbol-read' && inputPaths.length > 0) {
|
|
272
|
+
score = Math.max(1, score - 1);
|
|
273
|
+
reasons.push('Follow-up helper after code/outline identifies a symbol line or source anchor.');
|
|
274
|
+
}
|
|
275
|
+
if (score > 0 && script.readOnly && !script.mutates && !script.network) {
|
|
154
276
|
score += 1;
|
|
155
277
|
reasons.push('Read-only, non-mutating, offline helper.');
|
|
156
278
|
}
|
|
279
|
+
if (script.ref === 'repo/generated-boundary' && requestedSurfaces.has('generated')) {
|
|
280
|
+
score += 2;
|
|
281
|
+
reasons.push('Prioritizes generated-boundary checks for generated paths.');
|
|
282
|
+
}
|
|
157
283
|
if (score === 0) {
|
|
158
284
|
return null;
|
|
159
285
|
}
|
|
@@ -173,7 +299,7 @@ export function createScriptPackSuggestionReport(mustflowRoot, options) {
|
|
|
173
299
|
risk_level: script.riskLevel,
|
|
174
300
|
cost: script.cost,
|
|
175
301
|
report_schema_file: script.reportSchemaFile,
|
|
176
|
-
run_hint: script
|
|
302
|
+
run_hint: createRunHint(script, analyzedPaths),
|
|
177
303
|
};
|
|
178
304
|
})
|
|
179
305
|
.filter((suggestion) => suggestion !== null)
|
|
@@ -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
|
+
}
|
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { SECRET_LIKE_PATTERNS, textContainsSecretLike } from './secret-redaction.js';
|
|
4
|
-
export const SOURCE_ANCHOR_EXTENSIONS = new Set([
|
|
4
|
+
export const SOURCE_ANCHOR_EXTENSIONS = new Set([
|
|
5
|
+
'.astro',
|
|
6
|
+
'.cjs',
|
|
7
|
+
'.go',
|
|
8
|
+
'.js',
|
|
9
|
+
'.jsx',
|
|
10
|
+
'.mjs',
|
|
11
|
+
'.py',
|
|
12
|
+
'.rs',
|
|
13
|
+
'.svelte',
|
|
14
|
+
'.ts',
|
|
15
|
+
'.tsx',
|
|
16
|
+
]);
|
|
5
17
|
export const SOURCE_ANCHOR_DEFAULT_EXCLUDED_PATH_PARTS = new Set([
|
|
6
18
|
'.git',
|
|
7
19
|
'.mustflow',
|