mustflow 2.84.0 → 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 +11 -2
- package/dist/cli/commands/script-pack.js +4 -0
- package/dist/cli/i18n/en.js +79 -0
- package/dist/cli/i18n/es.js +79 -0
- package/dist/cli/i18n/fr.js +79 -0
- package/dist/cli/i18n/hi.js +79 -0
- package/dist/cli/i18n/ko.js +79 -0
- package/dist/cli/i18n/zh.js +79 -0
- 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 +114 -0
- 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/repo-env-contract.js +156 -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/config-loading.js +121 -4
- package/dist/core/dependency-graph.js +490 -0
- package/dist/core/env-contract.js +450 -0
- package/dist/core/line-endings.js +26 -13
- package/dist/core/public-json-contracts.js +51 -0
- package/dist/core/route-outline.js +57 -5
- package/dist/core/script-pack-suggestions.js +21 -1
- package/dist/core/secret-risk-scan.js +440 -0
- package/package.json +1 -1
- package/schemas/README.md +16 -0
- package/schemas/change-impact-report.schema.json +150 -0
- package/schemas/commands.schema.json +12 -0
- package/schemas/dependency-graph-report.schema.json +149 -0
- package/schemas/env-contract-report.schema.json +203 -0
- package/schemas/secret-risk-scan-report.schema.json +152 -0
- package/templates/default/i18n.toml +16 -10
- package/templates/default/locales/en/.mustflow/skills/INDEX.md +7 -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 +23 -8
- 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 +15 -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 +8 -1
|
@@ -2,13 +2,14 @@ import { spawnSync } from 'node:child_process';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
const CODE_NAVIGATION_SCRIPT_REFS = new Set([
|
|
4
4
|
'code/outline',
|
|
5
|
+
'code/dependency-graph',
|
|
5
6
|
'code/symbol-read',
|
|
6
7
|
'code/route-outline',
|
|
7
8
|
'code/export-diff',
|
|
8
9
|
'repo/related-files',
|
|
9
10
|
]);
|
|
10
11
|
const CONFIG_CHAIN_SURFACES = new Set(['config', 'package', 'source', 'test']);
|
|
11
|
-
const CONFIG_FILE_PATTERN = /(?:^|\/)(?: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;
|
|
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;
|
|
12
13
|
export function isScriptPackSuggestionPhase(value) {
|
|
13
14
|
return ['before_change', 'during_change', 'after_change', 'review'].includes(value);
|
|
14
15
|
}
|
|
@@ -166,6 +167,13 @@ function createRunHint(script, analyzedPaths) {
|
|
|
166
167
|
}
|
|
167
168
|
return 'After code/outline returns a source anchor, run: mf script-pack run code/symbol-read read --anchor <anchor-id> --json';
|
|
168
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
|
+
}
|
|
169
177
|
if (script.ref === 'code/route-outline') {
|
|
170
178
|
const sourcePaths = pathsWithSurface(analyzedPaths, 'source');
|
|
171
179
|
return createConcretePathHint('mf script-pack run code/route-outline scan', sourcePaths, script.usage);
|
|
@@ -200,6 +208,18 @@ function createRunHint(script, analyzedPaths) {
|
|
|
200
208
|
.map((entry) => entry.path);
|
|
201
209
|
return createConcretePathHint('mf script-pack run repo/config-chain inspect', configPaths, script.usage);
|
|
202
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
|
+
}
|
|
203
223
|
if (script.ref === 'repo/related-files') {
|
|
204
224
|
const relatedPaths = analyzedPaths
|
|
205
225
|
.filter((entry) => entry.surfaces.some((surface) => surface === 'source' || surface === 'test'))
|
|
@@ -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
|
+
}
|
package/package.json
CHANGED
package/schemas/README.md
CHANGED
|
@@ -69,6 +69,14 @@ Current schemas:
|
|
|
69
69
|
file hashes, language metadata, symbol names, declaration kinds, line ranges, signatures, export
|
|
70
70
|
flags, source-anchor navigation metadata, static return metadata, and stable input-limit finding
|
|
71
71
|
codes
|
|
72
|
+
- `dependency-graph-report.schema.json`: output of
|
|
73
|
+
`mf script-pack run code/dependency-graph scan <path...> --json`, containing bounded relative
|
|
74
|
+
TypeScript and JavaScript import graph nodes, edges, cycle hints, target flags, depth, importer
|
|
75
|
+
counts, policy limits, and stable input-limit finding codes
|
|
76
|
+
- `change-impact-report.schema.json`: output of
|
|
77
|
+
`mf script-pack run code/change-impact analyze [path...] --json`, containing git-diff changed
|
|
78
|
+
files, surface classifications, bounded impact candidates, optional script-pack hints,
|
|
79
|
+
verification intent hints, policy limits, and stable git or input-limit finding codes
|
|
72
80
|
- `code-symbol-read-report.schema.json`: output of
|
|
73
81
|
`mf script-pack run code/symbol-read read <path> --start-line <line> --json`, containing a
|
|
74
82
|
focused source snippet selected by source anchor, outline symbol line, or explicit line range with
|
|
@@ -91,6 +99,14 @@ Current schemas:
|
|
|
91
99
|
`mf script-pack run repo/config-chain inspect <path...> --json`, containing nearby package,
|
|
92
100
|
TypeScript, ESLint, Prettier, Vite, Vitest, Tailwind, Jest, Playwright, and mustflow config files
|
|
93
101
|
with static inheritance, reference, workspace, dynamic-config, path, and content-hash metadata
|
|
102
|
+
- `env-contract-report.schema.json`: output of
|
|
103
|
+
`mf script-pack run repo/env-contract scan [path...] --json`, containing code, CI, docs, config,
|
|
104
|
+
and env-example references for environment-variable contract drift without reading secret env files
|
|
105
|
+
or printing env values
|
|
106
|
+
- `secret-risk-scan-report.schema.json`: output of
|
|
107
|
+
`mf script-pack run repo/secret-risk-scan scan [path...] --json`, containing plausible
|
|
108
|
+
hardcoded-secret findings with detector names, paths, line numbers, and redacted fingerprints
|
|
109
|
+
without printing secret values
|
|
94
110
|
- `text-budget-report.schema.json`: output of
|
|
95
111
|
`mf script-pack run core/text-budget check <path...> --json`, containing
|
|
96
112
|
exact text-budget metrics, input content hashes, policy metadata, findings, and JSON Pointer field
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://mustflow.github.io/schemas/change-impact-report.schema.json",
|
|
4
|
+
"title": "mustflow change-impact report",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": [
|
|
8
|
+
"schema_version",
|
|
9
|
+
"command",
|
|
10
|
+
"pack_id",
|
|
11
|
+
"script_id",
|
|
12
|
+
"script_ref",
|
|
13
|
+
"action",
|
|
14
|
+
"status",
|
|
15
|
+
"ok",
|
|
16
|
+
"mustflow_root",
|
|
17
|
+
"policy",
|
|
18
|
+
"input_hash",
|
|
19
|
+
"changed_files",
|
|
20
|
+
"impacts",
|
|
21
|
+
"script_hints",
|
|
22
|
+
"verification_hints",
|
|
23
|
+
"truncated",
|
|
24
|
+
"findings",
|
|
25
|
+
"issues"
|
|
26
|
+
],
|
|
27
|
+
"properties": {
|
|
28
|
+
"schema_version": { "const": "1" },
|
|
29
|
+
"command": { "const": "script-pack" },
|
|
30
|
+
"pack_id": { "const": "code" },
|
|
31
|
+
"script_id": { "const": "change-impact" },
|
|
32
|
+
"script_ref": { "const": "code/change-impact" },
|
|
33
|
+
"action": { "const": "analyze" },
|
|
34
|
+
"status": { "enum": ["passed", "failed", "error"] },
|
|
35
|
+
"ok": { "type": "boolean" },
|
|
36
|
+
"mustflow_root": { "type": "string" },
|
|
37
|
+
"policy": { "$ref": "#/$defs/policy" },
|
|
38
|
+
"input_hash": { "$ref": "#/$defs/sha256" },
|
|
39
|
+
"changed_files": { "type": "array", "items": { "$ref": "#/$defs/changedFile" } },
|
|
40
|
+
"impacts": { "type": "array", "items": { "$ref": "#/$defs/impact" } },
|
|
41
|
+
"script_hints": { "type": "array", "items": { "$ref": "#/$defs/scriptHint" } },
|
|
42
|
+
"verification_hints": { "type": "array", "items": { "$ref": "#/$defs/verificationHint" } },
|
|
43
|
+
"truncated": { "type": "boolean" },
|
|
44
|
+
"findings": { "type": "array", "items": { "$ref": "#/$defs/finding" } },
|
|
45
|
+
"issues": { "type": "array", "items": { "type": "string" } }
|
|
46
|
+
},
|
|
47
|
+
"$defs": {
|
|
48
|
+
"sha256": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" },
|
|
49
|
+
"surface": {
|
|
50
|
+
"enum": ["source", "test", "docs", "schema", "config", "package", "template", "workflow", "i18n", "unknown"]
|
|
51
|
+
},
|
|
52
|
+
"policy": {
|
|
53
|
+
"type": "object",
|
|
54
|
+
"additionalProperties": false,
|
|
55
|
+
"required": [
|
|
56
|
+
"base_ref",
|
|
57
|
+
"head_ref",
|
|
58
|
+
"compare_worktree",
|
|
59
|
+
"max_files",
|
|
60
|
+
"max_impacts",
|
|
61
|
+
"max_file_bytes",
|
|
62
|
+
"path_filters"
|
|
63
|
+
],
|
|
64
|
+
"properties": {
|
|
65
|
+
"base_ref": { "type": "string" },
|
|
66
|
+
"head_ref": { "type": ["string", "null"] },
|
|
67
|
+
"compare_worktree": { "type": "boolean" },
|
|
68
|
+
"max_files": { "type": "integer", "minimum": 1 },
|
|
69
|
+
"max_impacts": { "type": "integer", "minimum": 1 },
|
|
70
|
+
"max_file_bytes": { "type": "integer", "minimum": 1 },
|
|
71
|
+
"path_filters": { "type": "array", "items": { "type": "string" } }
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
"changedFile": {
|
|
75
|
+
"type": "object",
|
|
76
|
+
"additionalProperties": false,
|
|
77
|
+
"required": ["path", "previous_path", "status", "surface"],
|
|
78
|
+
"properties": {
|
|
79
|
+
"path": { "type": "string" },
|
|
80
|
+
"previous_path": { "type": ["string", "null"] },
|
|
81
|
+
"status": { "enum": ["added", "modified", "deleted", "renamed", "type_changed", "untracked"] },
|
|
82
|
+
"surface": { "$ref": "#/$defs/surface" }
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
"impact": {
|
|
86
|
+
"type": "object",
|
|
87
|
+
"additionalProperties": false,
|
|
88
|
+
"required": ["path", "exists", "surface", "relationship", "confidence", "reason", "source_path"],
|
|
89
|
+
"properties": {
|
|
90
|
+
"path": { "type": "string" },
|
|
91
|
+
"exists": { "type": "boolean" },
|
|
92
|
+
"surface": { "$ref": "#/$defs/surface" },
|
|
93
|
+
"relationship": {
|
|
94
|
+
"enum": [
|
|
95
|
+
"changed_file",
|
|
96
|
+
"imports_changed_file",
|
|
97
|
+
"sibling_test",
|
|
98
|
+
"sibling_source",
|
|
99
|
+
"contract_doc",
|
|
100
|
+
"contract_test",
|
|
101
|
+
"package_surface",
|
|
102
|
+
"workflow_surface"
|
|
103
|
+
]
|
|
104
|
+
},
|
|
105
|
+
"confidence": { "type": "number", "minimum": 0, "maximum": 1 },
|
|
106
|
+
"reason": { "type": "string" },
|
|
107
|
+
"source_path": { "type": "string" }
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
"scriptHint": {
|
|
111
|
+
"type": "object",
|
|
112
|
+
"additionalProperties": false,
|
|
113
|
+
"required": ["script_ref", "command", "reason", "confidence"],
|
|
114
|
+
"properties": {
|
|
115
|
+
"script_ref": { "type": "string" },
|
|
116
|
+
"command": { "type": "string" },
|
|
117
|
+
"reason": { "type": "string" },
|
|
118
|
+
"confidence": { "type": "number", "minimum": 0, "maximum": 1 }
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
"verificationHint": {
|
|
122
|
+
"type": "object",
|
|
123
|
+
"additionalProperties": false,
|
|
124
|
+
"required": ["intent", "reason", "confidence"],
|
|
125
|
+
"properties": {
|
|
126
|
+
"intent": { "type": "string" },
|
|
127
|
+
"reason": { "type": "string" },
|
|
128
|
+
"confidence": { "type": "number", "minimum": 0, "maximum": 1 }
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
"finding": {
|
|
132
|
+
"type": "object",
|
|
133
|
+
"additionalProperties": false,
|
|
134
|
+
"required": ["code", "severity", "message", "path"],
|
|
135
|
+
"properties": {
|
|
136
|
+
"code": {
|
|
137
|
+
"enum": [
|
|
138
|
+
"change_impact_git_unavailable",
|
|
139
|
+
"change_impact_invalid_ref",
|
|
140
|
+
"change_impact_max_files_exceeded",
|
|
141
|
+
"change_impact_max_impacts_exceeded"
|
|
142
|
+
]
|
|
143
|
+
},
|
|
144
|
+
"severity": { "enum": ["low", "medium", "high", "critical"] },
|
|
145
|
+
"message": { "type": "string" },
|
|
146
|
+
"path": { "type": "string" }
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|