@prodcycle/prodcycle 0.2.2 → 0.4.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.
- package/dist/cli.js +480 -39
- package/dist/formatters/prompt.d.ts +5 -1
- package/dist/formatters/prompt.js +31 -2
- package/dist/formatters/sarif.d.ts +1 -1
- package/dist/formatters/sarif.js +66 -1
- package/dist/formatters/table.d.ts +1 -1
- package/dist/formatters/table.js +38 -4
- package/dist/index.d.ts +0 -15
- package/dist/index.js +0 -18
- package/dist/utils/fs.js +175 -54
- package/package.json +2 -3
package/dist/cli.js
CHANGED
|
@@ -38,12 +38,71 @@ const commander_1 = require("commander");
|
|
|
38
38
|
const fs = __importStar(require("fs"));
|
|
39
39
|
const path = __importStar(require("path"));
|
|
40
40
|
const index_1 = require("./index");
|
|
41
|
+
const table_1 = require("./formatters/table");
|
|
42
|
+
const sarif_1 = require("./formatters/sarif");
|
|
43
|
+
const prompt_1 = require("./formatters/prompt");
|
|
44
|
+
const KNOWN_COMMANDS = new Set([
|
|
45
|
+
'scan',
|
|
46
|
+
'gate',
|
|
47
|
+
'hook',
|
|
48
|
+
'init',
|
|
49
|
+
'help',
|
|
50
|
+
'--help',
|
|
51
|
+
'-h',
|
|
52
|
+
'--version',
|
|
53
|
+
'-V',
|
|
54
|
+
]);
|
|
55
|
+
/**
|
|
56
|
+
* Back-compat shim: `prodcycle .` used to scan the current directory with no
|
|
57
|
+
* subcommand. Preserve that behavior by injecting `scan` when the first arg
|
|
58
|
+
* isn't a known subcommand or a global flag.
|
|
59
|
+
*/
|
|
60
|
+
function injectScanDefault(argv) {
|
|
61
|
+
const args = argv.slice(2);
|
|
62
|
+
if (args.length === 0)
|
|
63
|
+
return [...argv.slice(0, 2), 'scan'];
|
|
64
|
+
if (KNOWN_COMMANDS.has(args[0]))
|
|
65
|
+
return argv;
|
|
66
|
+
return [...argv.slice(0, 2), 'scan', ...args];
|
|
67
|
+
}
|
|
68
|
+
function renderReport(response, format) {
|
|
69
|
+
switch (format) {
|
|
70
|
+
case 'json':
|
|
71
|
+
return JSON.stringify(response, null, 2);
|
|
72
|
+
case 'sarif':
|
|
73
|
+
return JSON.stringify((0, sarif_1.formatSarif)(response), null, 2);
|
|
74
|
+
case 'prompt':
|
|
75
|
+
return (0, prompt_1.formatPrompt)(response);
|
|
76
|
+
case 'table':
|
|
77
|
+
default:
|
|
78
|
+
return (0, table_1.formatTable)(response);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function writeOutput(text, outFile) {
|
|
82
|
+
if (outFile) {
|
|
83
|
+
fs.writeFileSync(outFile, text);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
process.stdout.write(text.endsWith('\n') ? text : text + '\n');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function parseList(val) {
|
|
90
|
+
if (!val)
|
|
91
|
+
return undefined;
|
|
92
|
+
return val
|
|
93
|
+
.split(',')
|
|
94
|
+
.map((s) => s.trim())
|
|
95
|
+
.filter(Boolean);
|
|
96
|
+
}
|
|
41
97
|
const program = new commander_1.Command();
|
|
42
98
|
program
|
|
43
99
|
.name('prodcycle')
|
|
44
100
|
.description('Multi-framework policy-as-code compliance scanner for infrastructure and application code.')
|
|
45
|
-
.version('0.
|
|
46
|
-
|
|
101
|
+
.version('0.4.0');
|
|
102
|
+
// ── scan ────────────────────────────────────────────────────────────────────
|
|
103
|
+
program
|
|
104
|
+
.command('scan [repo_path]')
|
|
105
|
+
.description('Scan a repository for compliance violations')
|
|
47
106
|
.option('--framework <ids>', 'Comma-separated framework IDs to evaluate', 'soc2')
|
|
48
107
|
.option('--format <format>', 'Output format: json, sarif, table, prompt', 'table')
|
|
49
108
|
.option('--severity-threshold <severity>', 'Minimum severity to include in report', 'low')
|
|
@@ -53,53 +112,72 @@ program
|
|
|
53
112
|
.option('--output <file>', 'Write report to file')
|
|
54
113
|
.option('--api-url <url>', 'Compliance API base URL (or PC_API_URL env)')
|
|
55
114
|
.option('--api-key <key>', 'API key for compliance API (or PC_API_KEY env)')
|
|
56
|
-
.option('--hook', 'Run as coding agent post-edit hook (reads stdin)')
|
|
57
|
-
.option('--hook-file <path>', 'File path for hook mode (alternative to stdin)')
|
|
58
|
-
.option('--hook-api', 'Run as API-based hook (calls hosted compliance API)')
|
|
59
|
-
.option('--init', 'Set up compliance hooks for coding agents')
|
|
60
|
-
.option('--agent <agents>', 'Comma-separated agents to configure')
|
|
61
115
|
.action(async (repoPath, opts) => {
|
|
62
116
|
try {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
68
|
-
if (opts.init) {
|
|
69
|
-
// Implement init logic here
|
|
70
|
-
console.log('Init mode executed.');
|
|
71
|
-
process.exit(0);
|
|
72
|
-
}
|
|
73
|
-
const frameworks = opts.framework.split(',').map((s) => s.trim());
|
|
74
|
-
const failOn = opts.failOn.split(',').map((s) => s.trim());
|
|
75
|
-
const include = opts.include ? opts.include.split(',') : undefined;
|
|
76
|
-
const exclude = opts.exclude ? opts.exclude.split(',') : undefined;
|
|
77
|
-
console.log(`Scanning ${path.resolve(repoPath)} for ${frameworks.join(', ')}...`);
|
|
117
|
+
const target = repoPath ?? '.';
|
|
118
|
+
const frameworks = parseList(opts.framework) ?? ['soc2'];
|
|
119
|
+
const failOn = parseList(opts.failOn) ?? ['critical', 'high'];
|
|
120
|
+
const format = (opts.format ?? 'table');
|
|
121
|
+
console.error(`Scanning ${path.resolve(target)} for ${frameworks.join(', ')}...`);
|
|
78
122
|
const response = await (0, index_1.scan)({
|
|
79
|
-
repoPath,
|
|
123
|
+
repoPath: target,
|
|
80
124
|
frameworks,
|
|
81
125
|
options: {
|
|
82
126
|
severityThreshold: opts.severityThreshold,
|
|
83
|
-
failOn,
|
|
84
|
-
include,
|
|
85
|
-
exclude,
|
|
127
|
+
failOn: failOn,
|
|
128
|
+
include: parseList(opts.include),
|
|
129
|
+
exclude: parseList(opts.exclude),
|
|
86
130
|
apiUrl: opts.apiUrl,
|
|
87
131
|
apiKey: opts.apiKey,
|
|
88
|
-
}
|
|
132
|
+
},
|
|
89
133
|
});
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
134
|
+
writeOutput(renderReport(response, format), opts.output);
|
|
135
|
+
process.exit(response.exitCode);
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
console.error(`\u2717 Error: ${error.message}`);
|
|
139
|
+
process.exit(2);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
// ── gate ────────────────────────────────────────────────────────────────────
|
|
143
|
+
program
|
|
144
|
+
.command('gate')
|
|
145
|
+
.description('Evaluate a JSON payload of files from stdin (low-latency hook endpoint)')
|
|
146
|
+
.option('--framework <ids>', 'Comma-separated framework IDs to evaluate', 'soc2')
|
|
147
|
+
.option('--format <format>', 'Output format: json, sarif, table, prompt', 'prompt')
|
|
148
|
+
.option('--output <file>', 'Write report to file')
|
|
149
|
+
.option('--api-url <url>', 'Compliance API base URL (or PC_API_URL env)')
|
|
150
|
+
.option('--api-key <key>', 'API key for compliance API (or PC_API_KEY env)')
|
|
151
|
+
.action(async (opts) => {
|
|
152
|
+
try {
|
|
153
|
+
const frameworks = parseList(opts.framework) ?? ['soc2'];
|
|
154
|
+
const format = (opts.format ?? 'prompt');
|
|
155
|
+
const stdin = await readStdin();
|
|
156
|
+
if (!stdin.trim()) {
|
|
157
|
+
console.error('gate: no input on stdin. Expected JSON payload: {"files": {...}}');
|
|
158
|
+
process.exit(2);
|
|
159
|
+
}
|
|
160
|
+
let payload;
|
|
161
|
+
try {
|
|
162
|
+
payload = JSON.parse(stdin);
|
|
98
163
|
}
|
|
99
|
-
|
|
100
|
-
console.
|
|
101
|
-
|
|
164
|
+
catch (e) {
|
|
165
|
+
console.error(`gate: invalid JSON on stdin: ${e.message}`);
|
|
166
|
+
process.exit(2);
|
|
167
|
+
return;
|
|
102
168
|
}
|
|
169
|
+
if (!payload.files || typeof payload.files !== 'object') {
|
|
170
|
+
console.error('gate: payload must include a "files" object of {path: content}');
|
|
171
|
+
process.exit(2);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const response = await (0, index_1.gate)({
|
|
175
|
+
files: payload.files,
|
|
176
|
+
frameworks,
|
|
177
|
+
apiUrl: opts.apiUrl,
|
|
178
|
+
apiKey: opts.apiKey,
|
|
179
|
+
});
|
|
180
|
+
writeOutput(renderReport(response, format), opts.output);
|
|
103
181
|
process.exit(response.exitCode);
|
|
104
182
|
}
|
|
105
183
|
catch (error) {
|
|
@@ -107,4 +185,367 @@ program
|
|
|
107
185
|
process.exit(2);
|
|
108
186
|
}
|
|
109
187
|
});
|
|
110
|
-
|
|
188
|
+
// ── hook ────────────────────────────────────────────────────────────────────
|
|
189
|
+
program
|
|
190
|
+
.command('hook')
|
|
191
|
+
.description('Run as coding-agent post-edit hook (reads stdin or --file)')
|
|
192
|
+
.option('--framework <ids>', 'Comma-separated framework IDs to evaluate', 'soc2')
|
|
193
|
+
.option('--format <format>', 'Output format: json, sarif, table, prompt', 'prompt')
|
|
194
|
+
.option('--file <path>', 'Scan this file from disk (alternative to reading content from stdin)')
|
|
195
|
+
.option('--fail-on <levels>', 'Severities that cause non-zero exit', 'critical,high')
|
|
196
|
+
.option('--output <file>', 'Write report to file')
|
|
197
|
+
.option('--api-url <url>', 'Compliance API base URL (or PC_API_URL env)')
|
|
198
|
+
.option('--api-key <key>', 'API key for compliance API (or PC_API_KEY env)')
|
|
199
|
+
.action(async (opts) => {
|
|
200
|
+
try {
|
|
201
|
+
const frameworks = parseList(opts.framework) ?? ['soc2'];
|
|
202
|
+
const format = (opts.format ?? 'prompt');
|
|
203
|
+
const files = await collectHookFiles(opts.file);
|
|
204
|
+
if (!files || Object.keys(files).length === 0) {
|
|
205
|
+
// No files to check — exit clean so the agent proceeds.
|
|
206
|
+
process.exit(0);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const response = await (0, index_1.gate)({
|
|
210
|
+
files,
|
|
211
|
+
frameworks,
|
|
212
|
+
apiUrl: opts.apiUrl,
|
|
213
|
+
apiKey: opts.apiKey,
|
|
214
|
+
});
|
|
215
|
+
writeOutput(renderReport(response, format), opts.output);
|
|
216
|
+
process.exit(response.exitCode);
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
console.error(`\u2717 Error: ${error.message}`);
|
|
220
|
+
process.exit(2);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
/**
|
|
224
|
+
* Resolve the files to scan for a `hook` invocation. Supports:
|
|
225
|
+
* - `--file <path>` — read that file from disk
|
|
226
|
+
* - stdin: `{"files": {path: content}}` (same as gate)
|
|
227
|
+
* - stdin: `{"file_path": "...", "content": "..."}` (single file)
|
|
228
|
+
* - stdin: Claude Code PostToolUse shape —
|
|
229
|
+
* `{"tool_input": {"file_path": "...", "content"|"new_string": "..."}}`
|
|
230
|
+
* When only `file_path` is given and we can read the file, we do.
|
|
231
|
+
*/
|
|
232
|
+
async function collectHookFiles(filePath) {
|
|
233
|
+
if (filePath) {
|
|
234
|
+
const absolute = path.resolve(filePath);
|
|
235
|
+
if (!fs.existsSync(absolute)) {
|
|
236
|
+
console.error(`hook: --file path does not exist: ${absolute}`);
|
|
237
|
+
process.exit(2);
|
|
238
|
+
}
|
|
239
|
+
const content = fs.readFileSync(absolute, 'utf8');
|
|
240
|
+
return { [filePath]: content };
|
|
241
|
+
}
|
|
242
|
+
const stdin = await readStdin();
|
|
243
|
+
if (!stdin.trim()) {
|
|
244
|
+
console.error('hook: no input. Provide --file <path> or JSON on stdin (see `prodcycle hook --help`).');
|
|
245
|
+
process.exit(2);
|
|
246
|
+
}
|
|
247
|
+
let payload;
|
|
248
|
+
try {
|
|
249
|
+
payload = JSON.parse(stdin);
|
|
250
|
+
}
|
|
251
|
+
catch (e) {
|
|
252
|
+
console.error(`hook: invalid JSON on stdin: ${e.message}`);
|
|
253
|
+
process.exit(2);
|
|
254
|
+
}
|
|
255
|
+
// Shape 1: {"files": {path: content}} — gate-compatible
|
|
256
|
+
if (payload && typeof payload.files === 'object' && payload.files !== null) {
|
|
257
|
+
return payload.files;
|
|
258
|
+
}
|
|
259
|
+
// Shape 2: top-level single file. Shape 3: Claude Code tool_input nesting.
|
|
260
|
+
const candidate = payload?.tool_input ?? payload;
|
|
261
|
+
const hookFilePath = candidate?.file_path ?? candidate?.path;
|
|
262
|
+
const hookContent = candidate?.content ?? candidate?.new_string;
|
|
263
|
+
if (hookFilePath && typeof hookContent === 'string') {
|
|
264
|
+
return { [hookFilePath]: hookContent };
|
|
265
|
+
}
|
|
266
|
+
if (hookFilePath && fs.existsSync(hookFilePath)) {
|
|
267
|
+
// Only a path was given — read from disk so post-edit hooks still work
|
|
268
|
+
// when the agent doesn't ship the content inline.
|
|
269
|
+
const content = fs.readFileSync(hookFilePath, 'utf8');
|
|
270
|
+
return { [hookFilePath]: content };
|
|
271
|
+
}
|
|
272
|
+
console.error('hook: stdin payload not recognized. Expected one of:\n' +
|
|
273
|
+
' {"files": {"path": "content"}}\n' +
|
|
274
|
+
' {"file_path": "...", "content": "..."}\n' +
|
|
275
|
+
' {"tool_input": {"file_path": "...", "content": "..."}}');
|
|
276
|
+
process.exit(2);
|
|
277
|
+
return null; // unreachable
|
|
278
|
+
}
|
|
279
|
+
// ── init ────────────────────────────────────────────────────────────────────
|
|
280
|
+
program
|
|
281
|
+
.command('init')
|
|
282
|
+
.description('Configure compliance hooks for coding agents')
|
|
283
|
+
.option('--agent <agents>', 'Comma-separated agents to configure (claude, cursor, codex, opencode, github-copilot, gemini-cli). Use "all" to configure every agent. Default: auto-detect.')
|
|
284
|
+
.option('--force', 'Overwrite existing compliance hook entries')
|
|
285
|
+
.option('--dir <path>', 'Project directory to configure', '.')
|
|
286
|
+
.action((opts) => {
|
|
287
|
+
try {
|
|
288
|
+
const dir = path.resolve(opts.dir ?? '.');
|
|
289
|
+
const agents = resolveAgents(opts.agent, dir);
|
|
290
|
+
if (agents.length === 0) {
|
|
291
|
+
console.error('init: no agents selected and none auto-detected. ' +
|
|
292
|
+
'Use --agent <name> to configure explicitly (claude, cursor, codex, ' +
|
|
293
|
+
'opencode, github-copilot, gemini-cli, or "all").');
|
|
294
|
+
process.exit(2);
|
|
295
|
+
}
|
|
296
|
+
let anyFailed = false;
|
|
297
|
+
const writtenPaths = new Set();
|
|
298
|
+
for (const agent of agents) {
|
|
299
|
+
const result = configureAgent(agent, dir, !!opts.force, writtenPaths);
|
|
300
|
+
process.stdout.write(result.message + '\n');
|
|
301
|
+
if (result.status === 'failed')
|
|
302
|
+
anyFailed = true;
|
|
303
|
+
}
|
|
304
|
+
process.exit(anyFailed ? 1 : 0);
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
console.error(`\u2717 Error: ${error.message}`);
|
|
308
|
+
process.exit(2);
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
const ALL_AGENTS = [
|
|
312
|
+
'claude',
|
|
313
|
+
'cursor',
|
|
314
|
+
'codex',
|
|
315
|
+
'opencode',
|
|
316
|
+
'github-copilot',
|
|
317
|
+
'gemini-cli',
|
|
318
|
+
];
|
|
319
|
+
function isAgentName(name) {
|
|
320
|
+
return ALL_AGENTS.includes(name);
|
|
321
|
+
}
|
|
322
|
+
function resolveAgents(userChoice, dir) {
|
|
323
|
+
if (userChoice) {
|
|
324
|
+
const list = parseList(userChoice) ?? [];
|
|
325
|
+
if (list.length === 1 && list[0] === 'all')
|
|
326
|
+
return ALL_AGENTS.slice();
|
|
327
|
+
const valid = [];
|
|
328
|
+
for (const name of list) {
|
|
329
|
+
if (isAgentName(name))
|
|
330
|
+
valid.push(name);
|
|
331
|
+
else
|
|
332
|
+
console.error(`init: unknown agent "${name}" — ignoring`);
|
|
333
|
+
}
|
|
334
|
+
return valid;
|
|
335
|
+
}
|
|
336
|
+
// Auto-detect: look for config dirs/files that indicate the agent is already in use.
|
|
337
|
+
const detected = [];
|
|
338
|
+
if (fs.existsSync(path.join(dir, '.claude')))
|
|
339
|
+
detected.push('claude');
|
|
340
|
+
if (fs.existsSync(path.join(dir, '.cursor')))
|
|
341
|
+
detected.push('cursor');
|
|
342
|
+
if (fs.existsSync(path.join(dir, '.codex')))
|
|
343
|
+
detected.push('codex');
|
|
344
|
+
if (fs.existsSync(path.join(dir, '.opencode')))
|
|
345
|
+
detected.push('opencode');
|
|
346
|
+
if (fs.existsSync(path.join(dir, '.github', 'copilot-instructions.md'))) {
|
|
347
|
+
detected.push('github-copilot');
|
|
348
|
+
}
|
|
349
|
+
if (fs.existsSync(path.join(dir, 'GEMINI.md')) ||
|
|
350
|
+
fs.existsSync(path.join(dir, '.gemini'))) {
|
|
351
|
+
detected.push('gemini-cli');
|
|
352
|
+
}
|
|
353
|
+
return detected;
|
|
354
|
+
}
|
|
355
|
+
function configureAgent(agent, dir, force, writtenPaths) {
|
|
356
|
+
switch (agent) {
|
|
357
|
+
case 'claude':
|
|
358
|
+
return configureClaudeCode(dir, force);
|
|
359
|
+
case 'cursor':
|
|
360
|
+
return configureCursor(dir, force);
|
|
361
|
+
case 'codex':
|
|
362
|
+
return configureInstructionFile(agent, dir, 'AGENTS.md', force, writtenPaths);
|
|
363
|
+
case 'opencode':
|
|
364
|
+
return configureInstructionFile(agent, dir, 'AGENTS.md', force, writtenPaths);
|
|
365
|
+
case 'github-copilot':
|
|
366
|
+
return configureInstructionFile(agent, dir, path.join('.github', 'copilot-instructions.md'), force, writtenPaths);
|
|
367
|
+
case 'gemini-cli':
|
|
368
|
+
return configureInstructionFile(agent, dir, 'GEMINI.md', force, writtenPaths);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
const CLAUDE_MATCHER = 'Write|Edit|MultiEdit';
|
|
372
|
+
const CLAUDE_COMMAND = 'prodcycle hook';
|
|
373
|
+
function configureClaudeCode(dir, force) {
|
|
374
|
+
const claudeDir = path.join(dir, '.claude');
|
|
375
|
+
const settingsPath = path.join(claudeDir, 'settings.json');
|
|
376
|
+
let settings = {};
|
|
377
|
+
if (fs.existsSync(settingsPath)) {
|
|
378
|
+
try {
|
|
379
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
380
|
+
if (typeof settings !== 'object' || settings === null || Array.isArray(settings)) {
|
|
381
|
+
return {
|
|
382
|
+
status: 'failed',
|
|
383
|
+
message: `[claude] ${settingsPath} is not a JSON object — refusing to overwrite. Fix the file manually.`,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
catch (e) {
|
|
388
|
+
return {
|
|
389
|
+
status: 'failed',
|
|
390
|
+
message: `[claude] could not parse ${settingsPath}: ${e.message}. Fix the file manually.`,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
const hooks = (settings.hooks ??= {});
|
|
395
|
+
const postToolUse = (hooks.PostToolUse ??= []);
|
|
396
|
+
// Look for an existing prodcycle entry
|
|
397
|
+
const existing = postToolUse.find((b) => b.hooks?.some((h) => h.type === 'command' && h.command.trim().startsWith('prodcycle hook')));
|
|
398
|
+
if (existing && !force) {
|
|
399
|
+
return {
|
|
400
|
+
status: 'already',
|
|
401
|
+
message: `[claude] PostToolUse hook for prodcycle already present in ${settingsPath}. Use --force to rewrite.`,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
if (existing && force) {
|
|
405
|
+
// Replace in place — preserve the matcher, rewrite the command to the canonical form
|
|
406
|
+
existing.matcher = CLAUDE_MATCHER;
|
|
407
|
+
existing.hooks = [{ type: 'command', command: CLAUDE_COMMAND }];
|
|
408
|
+
}
|
|
409
|
+
else {
|
|
410
|
+
postToolUse.push({
|
|
411
|
+
matcher: CLAUDE_MATCHER,
|
|
412
|
+
hooks: [{ type: 'command', command: CLAUDE_COMMAND }],
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
if (!fs.existsSync(claudeDir))
|
|
416
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
417
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
418
|
+
return {
|
|
419
|
+
status: 'installed',
|
|
420
|
+
message: `[claude] wrote PostToolUse hook to ${settingsPath}. Requires PC_API_KEY in the environment when Claude Code runs.`,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
const CURSOR_COMMAND = 'prodcycle hook';
|
|
424
|
+
function configureCursor(dir, force) {
|
|
425
|
+
const cursorDir = path.join(dir, '.cursor');
|
|
426
|
+
const hooksPath = path.join(cursorDir, 'hooks.json');
|
|
427
|
+
let config = { version: 1 };
|
|
428
|
+
if (fs.existsSync(hooksPath)) {
|
|
429
|
+
try {
|
|
430
|
+
const parsed = JSON.parse(fs.readFileSync(hooksPath, 'utf8'));
|
|
431
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
432
|
+
return {
|
|
433
|
+
status: 'failed',
|
|
434
|
+
message: `[cursor] ${hooksPath} is not a JSON object — refusing to overwrite. Fix the file manually.`,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
config = parsed;
|
|
438
|
+
}
|
|
439
|
+
catch (e) {
|
|
440
|
+
return {
|
|
441
|
+
status: 'failed',
|
|
442
|
+
message: `[cursor] could not parse ${hooksPath}: ${e.message}. Fix the file manually.`,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
if (typeof config.version !== 'number')
|
|
447
|
+
config.version = 1;
|
|
448
|
+
const hooks = (config.hooks ??= {});
|
|
449
|
+
const afterFileEdit = (hooks.afterFileEdit ??= []);
|
|
450
|
+
const existing = afterFileEdit.find((h) => typeof h.command === 'string' && h.command.trim().startsWith('prodcycle hook'));
|
|
451
|
+
if (existing && !force) {
|
|
452
|
+
return {
|
|
453
|
+
status: 'already',
|
|
454
|
+
message: `[cursor] afterFileEdit hook for prodcycle already present in ${hooksPath}. Use --force to rewrite.`,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
if (existing && force) {
|
|
458
|
+
existing.command = CURSOR_COMMAND;
|
|
459
|
+
}
|
|
460
|
+
else {
|
|
461
|
+
afterFileEdit.push({ command: CURSOR_COMMAND });
|
|
462
|
+
}
|
|
463
|
+
if (!fs.existsSync(cursorDir))
|
|
464
|
+
fs.mkdirSync(cursorDir, { recursive: true });
|
|
465
|
+
fs.writeFileSync(hooksPath, JSON.stringify(config, null, 2) + '\n');
|
|
466
|
+
return {
|
|
467
|
+
status: 'installed',
|
|
468
|
+
message: `[cursor] wrote afterFileEdit hook to ${hooksPath}. Requires PC_API_KEY in the environment when Cursor runs.`,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
// ── Instruction-file agents (codex, opencode, github-copilot, gemini-cli) ───
|
|
472
|
+
const INSTRUCTION_BEGIN = '<!-- prodcycle:begin -->';
|
|
473
|
+
const INSTRUCTION_END = '<!-- prodcycle:end -->';
|
|
474
|
+
function buildInstructionBlock() {
|
|
475
|
+
return [
|
|
476
|
+
INSTRUCTION_BEGIN,
|
|
477
|
+
'## ProdCycle Compliance',
|
|
478
|
+
'',
|
|
479
|
+
'This project enforces SOC 2, HIPAA, and NIST CSF policies on infrastructure',
|
|
480
|
+
'and application code. After creating or modifying any file, run the compliance',
|
|
481
|
+
'scanner and fix every violation before continuing:',
|
|
482
|
+
'',
|
|
483
|
+
'```bash',
|
|
484
|
+
'npx prodcycle scan . --format prompt',
|
|
485
|
+
'```',
|
|
486
|
+
'',
|
|
487
|
+
'Each finding includes a severity, rule ID, message, and remediation guidance.',
|
|
488
|
+
'Apply the fixes, then re-run the scan until every check passes.',
|
|
489
|
+
INSTRUCTION_END,
|
|
490
|
+
'',
|
|
491
|
+
].join('\n');
|
|
492
|
+
}
|
|
493
|
+
function configureInstructionFile(agent, dir, relPath, force, writtenPaths) {
|
|
494
|
+
const fullPath = path.join(dir, relPath);
|
|
495
|
+
// Multiple agents may share the same target file (codex + opencode → AGENTS.md).
|
|
496
|
+
// Skip the second agent silently if we already wrote to the same file in this run.
|
|
497
|
+
if (writtenPaths.has(fullPath)) {
|
|
498
|
+
return {
|
|
499
|
+
status: 'already',
|
|
500
|
+
message: `[${agent}] ${relPath} already written in this run (shared with another agent).`,
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
let existing = '';
|
|
504
|
+
if (fs.existsSync(fullPath)) {
|
|
505
|
+
existing = fs.readFileSync(fullPath, 'utf8');
|
|
506
|
+
}
|
|
507
|
+
const block = buildInstructionBlock();
|
|
508
|
+
const hasBlock = existing.includes(INSTRUCTION_BEGIN) && existing.includes(INSTRUCTION_END);
|
|
509
|
+
if (hasBlock && !force) {
|
|
510
|
+
return {
|
|
511
|
+
status: 'already',
|
|
512
|
+
message: `[${agent}] prodcycle instruction block already present in ${fullPath}. Use --force to rewrite.`,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
let next;
|
|
516
|
+
if (hasBlock) {
|
|
517
|
+
const pattern = new RegExp(`${escapeRegExp(INSTRUCTION_BEGIN)}[\\s\\S]*?${escapeRegExp(INSTRUCTION_END)}\\n?`);
|
|
518
|
+
next = existing.replace(pattern, block);
|
|
519
|
+
}
|
|
520
|
+
else if (existing.trim().length === 0) {
|
|
521
|
+
next = block;
|
|
522
|
+
}
|
|
523
|
+
else {
|
|
524
|
+
next = existing.replace(/\n*$/, '\n\n') + block;
|
|
525
|
+
}
|
|
526
|
+
const parent = path.dirname(fullPath);
|
|
527
|
+
if (!fs.existsSync(parent))
|
|
528
|
+
fs.mkdirSync(parent, { recursive: true });
|
|
529
|
+
fs.writeFileSync(fullPath, next);
|
|
530
|
+
writtenPaths.add(fullPath);
|
|
531
|
+
return {
|
|
532
|
+
status: 'installed',
|
|
533
|
+
message: `[${agent}] wrote compliance instructions to ${fullPath}.`,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
function escapeRegExp(s) {
|
|
537
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
538
|
+
}
|
|
539
|
+
function readStdin() {
|
|
540
|
+
return new Promise((resolve, reject) => {
|
|
541
|
+
if (process.stdin.isTTY) {
|
|
542
|
+
resolve('');
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
const chunks = [];
|
|
546
|
+
process.stdin.on('data', (c) => chunks.push(c));
|
|
547
|
+
process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
548
|
+
process.stdin.on('error', reject);
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
program.parse(injectScanDefault(process.argv));
|
|
@@ -1 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Render a coding-agent-oriented prompt describing findings. If the server
|
|
3
|
+
* returned a pre-built `prompt` field (hook endpoint), prefer that.
|
|
4
|
+
*/
|
|
5
|
+
export declare function formatPrompt(report: unknown): string;
|
|
@@ -1,8 +1,37 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.formatPrompt = formatPrompt;
|
|
4
|
+
/**
|
|
5
|
+
* Render a coding-agent-oriented prompt describing findings. If the server
|
|
6
|
+
* returned a pre-built `prompt` field (hook endpoint), prefer that.
|
|
7
|
+
*/
|
|
4
8
|
function formatPrompt(report) {
|
|
5
|
-
|
|
9
|
+
const r = (report ?? {});
|
|
10
|
+
if (typeof r.prompt === 'string' && r.prompt.trim())
|
|
11
|
+
return r.prompt;
|
|
12
|
+
const findings = r.findings ?? [];
|
|
13
|
+
if (findings.length === 0)
|
|
14
|
+
return 'No compliance violations detected.';
|
|
15
|
+
const lines = [];
|
|
16
|
+
lines.push(`Compliance scan found ${findings.length} violation(s) that need to be addressed:`);
|
|
17
|
+
lines.push('');
|
|
18
|
+
for (const f of findings) {
|
|
19
|
+
const sev = (f.severity ?? 'unknown').toUpperCase();
|
|
20
|
+
const rule = f.rule_id ?? f.ruleId ?? 'unknown';
|
|
21
|
+
const loc = locOf(f);
|
|
22
|
+
const title = f.title ?? f.message ?? '';
|
|
23
|
+
lines.push(`- [${sev}] ${rule}${loc ? ` (${loc})` : ''}: ${title}`);
|
|
24
|
+
if (f.description && f.description !== title) {
|
|
25
|
+
lines.push(` ${f.description}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
lines.push('');
|
|
29
|
+
lines.push('Please update the code to resolve these issues before continuing.');
|
|
30
|
+
return lines.join('\n');
|
|
31
|
+
}
|
|
32
|
+
function locOf(f) {
|
|
33
|
+
const file = f.file ?? f.path;
|
|
34
|
+
if (!file)
|
|
6
35
|
return '';
|
|
7
|
-
return
|
|
36
|
+
return f.line ? `${file}:${f.line}` : file;
|
|
8
37
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare function formatSarif(report:
|
|
1
|
+
export declare function formatSarif(report: unknown): object;
|
package/dist/formatters/sarif.js
CHANGED
|
@@ -1,9 +1,74 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.formatSarif = formatSarif;
|
|
4
|
+
/**
|
|
5
|
+
* SARIF 2.1.0 level mapping for compliance severities.
|
|
6
|
+
* Spec: https://docs.oasis-open.org/sarif/sarif/v2.1.0/os/sarif-v2.1.0-os.html
|
|
7
|
+
*/
|
|
8
|
+
function sarifLevel(sev) {
|
|
9
|
+
const s = (sev ?? '').toLowerCase();
|
|
10
|
+
if (s === 'critical' || s === 'high')
|
|
11
|
+
return 'error';
|
|
12
|
+
if (s === 'medium')
|
|
13
|
+
return 'warning';
|
|
14
|
+
return 'note';
|
|
15
|
+
}
|
|
4
16
|
function formatSarif(report) {
|
|
17
|
+
const r = (report ?? {});
|
|
18
|
+
const findings = r.findings ?? [];
|
|
19
|
+
const rulesById = new Map();
|
|
20
|
+
const results = findings.map((f) => {
|
|
21
|
+
const ruleId = f.rule_id ?? f.ruleId ?? 'unknown';
|
|
22
|
+
if (!rulesById.has(ruleId)) {
|
|
23
|
+
rulesById.set(ruleId, {
|
|
24
|
+
id: ruleId,
|
|
25
|
+
name: ruleId,
|
|
26
|
+
shortDescription: { text: f.title ?? ruleId },
|
|
27
|
+
...(f.description ? { fullDescription: { text: f.description } } : {}),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
const file = f.file ?? f.path ?? '';
|
|
31
|
+
const startLine = f.line;
|
|
32
|
+
const endLine = f.end_line ?? f.endLine;
|
|
33
|
+
return {
|
|
34
|
+
ruleId,
|
|
35
|
+
level: sarifLevel(f.severity),
|
|
36
|
+
message: { text: f.message ?? f.title ?? ruleId },
|
|
37
|
+
...(file
|
|
38
|
+
? {
|
|
39
|
+
locations: [
|
|
40
|
+
{
|
|
41
|
+
physicalLocation: {
|
|
42
|
+
artifactLocation: { uri: file },
|
|
43
|
+
...(startLine
|
|
44
|
+
? {
|
|
45
|
+
region: {
|
|
46
|
+
startLine,
|
|
47
|
+
...(endLine ? { endLine } : {}),
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
: {}),
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
}
|
|
55
|
+
: {}),
|
|
56
|
+
};
|
|
57
|
+
});
|
|
5
58
|
return {
|
|
6
59
|
version: '2.1.0',
|
|
7
|
-
|
|
60
|
+
$schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
|
|
61
|
+
runs: [
|
|
62
|
+
{
|
|
63
|
+
tool: {
|
|
64
|
+
driver: {
|
|
65
|
+
name: 'ProdCycle Compliance Scanner',
|
|
66
|
+
informationUri: 'https://docs.prodcycle.com',
|
|
67
|
+
rules: [...rulesById.values()],
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
results,
|
|
71
|
+
},
|
|
72
|
+
],
|
|
8
73
|
};
|
|
9
74
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare function formatTable(report:
|
|
1
|
+
export declare function formatTable(report: unknown): string;
|
package/dist/formatters/table.js
CHANGED
|
@@ -1,9 +1,43 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.formatTable = formatTable;
|
|
4
|
+
const SEVERITY_ORDER = ['critical', 'high', 'medium', 'low', 'info'];
|
|
5
|
+
function sevRank(s) {
|
|
6
|
+
const i = SEVERITY_ORDER.indexOf((s ?? '').toLowerCase());
|
|
7
|
+
return i === -1 ? SEVERITY_ORDER.length : i;
|
|
8
|
+
}
|
|
4
9
|
function formatTable(report) {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
10
|
+
const r = (report ?? {});
|
|
11
|
+
const findings = r.findings ?? [];
|
|
12
|
+
if (findings.length === 0) {
|
|
13
|
+
return r.passed === false ? 'Scan failed but no findings returned.' : '\u2713 No compliance violations found.';
|
|
14
|
+
}
|
|
15
|
+
const sorted = [...findings].sort((a, b) => sevRank(a.severity) - sevRank(b.severity));
|
|
16
|
+
const counts = new Map();
|
|
17
|
+
for (const f of findings) {
|
|
18
|
+
const sev = (f.severity ?? 'unknown').toLowerCase();
|
|
19
|
+
counts.set(sev, (counts.get(sev) ?? 0) + 1);
|
|
20
|
+
}
|
|
21
|
+
const lines = [];
|
|
22
|
+
lines.push(`Findings: ${findings.length}`);
|
|
23
|
+
const summaryParts = SEVERITY_ORDER.filter((s) => counts.has(s)).map((s) => `${counts.get(s)} ${s}`);
|
|
24
|
+
if (summaryParts.length > 0)
|
|
25
|
+
lines.push(` ${summaryParts.join(', ')}`);
|
|
26
|
+
lines.push('');
|
|
27
|
+
for (const f of sorted) {
|
|
28
|
+
const sev = (f.severity ?? 'unknown').toUpperCase().padEnd(8);
|
|
29
|
+
const rule = f.rule_id ?? f.ruleId ?? '';
|
|
30
|
+
const loc = locOf(f);
|
|
31
|
+
const title = f.title ?? f.message ?? f.description ?? '';
|
|
32
|
+
lines.push(` [${sev}] ${rule} ${title}`);
|
|
33
|
+
if (loc)
|
|
34
|
+
lines.push(` ${loc}`);
|
|
35
|
+
}
|
|
36
|
+
return lines.join('\n');
|
|
37
|
+
}
|
|
38
|
+
function locOf(f) {
|
|
39
|
+
const file = f.file ?? f.path;
|
|
40
|
+
if (!file)
|
|
41
|
+
return '';
|
|
42
|
+
return f.line ? `${file}:${f.line}` : file;
|
|
9
43
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -33,18 +33,3 @@ export declare function gate(options: GateOptions): Promise<{
|
|
|
33
33
|
prompt: any;
|
|
34
34
|
summary: any;
|
|
35
35
|
}>;
|
|
36
|
-
/**
|
|
37
|
-
* Run local hook
|
|
38
|
-
*/
|
|
39
|
-
export declare function runHook(params: {
|
|
40
|
-
frameworks?: string[];
|
|
41
|
-
filePath?: string;
|
|
42
|
-
}): Promise<number>;
|
|
43
|
-
/**
|
|
44
|
-
* Run API hook
|
|
45
|
-
*/
|
|
46
|
-
export declare function runHookApi(params: {
|
|
47
|
-
apiUrl?: string;
|
|
48
|
-
apiKey?: string;
|
|
49
|
-
frameworks?: string[];
|
|
50
|
-
}): Promise<number>;
|
package/dist/index.js
CHANGED
|
@@ -16,8 +16,6 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
17
|
exports.scan = scan;
|
|
18
18
|
exports.gate = gate;
|
|
19
|
-
exports.runHook = runHook;
|
|
20
|
-
exports.runHookApi = runHookApi;
|
|
21
19
|
const api_client_1 = require("./api-client");
|
|
22
20
|
const fs_1 = require("./utils/fs");
|
|
23
21
|
__exportStar(require("./api-client"), exports);
|
|
@@ -64,19 +62,3 @@ async function gate(options) {
|
|
|
64
62
|
summary: response.summary
|
|
65
63
|
};
|
|
66
64
|
}
|
|
67
|
-
/**
|
|
68
|
-
* Run local hook
|
|
69
|
-
*/
|
|
70
|
-
async function runHook(params) {
|
|
71
|
-
// Logic to read stdin or specific file and call gate
|
|
72
|
-
const { frameworks = ['soc2'], filePath } = params;
|
|
73
|
-
// Implementation details...
|
|
74
|
-
return 0;
|
|
75
|
-
}
|
|
76
|
-
/**
|
|
77
|
-
* Run API hook
|
|
78
|
-
*/
|
|
79
|
-
async function runHookApi(params) {
|
|
80
|
-
// Implementation details...
|
|
81
|
-
return 0;
|
|
82
|
-
}
|
package/dist/utils/fs.js
CHANGED
|
@@ -36,66 +36,116 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
36
36
|
exports.collectFiles = collectFiles;
|
|
37
37
|
const fs = __importStar(require("fs"));
|
|
38
38
|
const path = __importStar(require("path"));
|
|
39
|
-
const
|
|
39
|
+
const minimatch_1 = require("minimatch");
|
|
40
40
|
const MAX_FILE_SIZE = 256 * 1024; // 256 KB
|
|
41
41
|
const MAX_TOTAL_FILES = 10_000;
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Directories skipped unconditionally. Kept in parity with
|
|
44
|
+
* `packages/compliance-code-scanner/src/ignore-utils.ts`.
|
|
45
|
+
*/
|
|
46
|
+
const SKIP_DIRS = new Set([
|
|
47
|
+
'node_modules',
|
|
48
|
+
'vendor',
|
|
49
|
+
'__pycache__',
|
|
50
|
+
'.terraform',
|
|
51
|
+
'.git',
|
|
52
|
+
'dist',
|
|
53
|
+
'.venv',
|
|
54
|
+
'venv',
|
|
55
|
+
'build',
|
|
56
|
+
'out',
|
|
57
|
+
'.next',
|
|
58
|
+
'.nuxt',
|
|
59
|
+
'.output',
|
|
60
|
+
'.cache',
|
|
61
|
+
'.parcel-cache',
|
|
62
|
+
'coverage',
|
|
63
|
+
'.nyc_output',
|
|
64
|
+
'.turbo',
|
|
65
|
+
'target',
|
|
66
|
+
'.gradle',
|
|
67
|
+
'.mvn',
|
|
68
|
+
'.idea',
|
|
69
|
+
'.vscode',
|
|
70
|
+
'.eggs',
|
|
71
|
+
'.tox',
|
|
72
|
+
'.mypy_cache',
|
|
73
|
+
'.ruff_cache',
|
|
74
|
+
'.pytest_cache',
|
|
75
|
+
'bower_components',
|
|
76
|
+
'.svn',
|
|
77
|
+
'.hg',
|
|
78
|
+
'__snapshots__',
|
|
79
|
+
]);
|
|
80
|
+
const SKIP_DIR_SUFFIXES = ['.egg-info'];
|
|
81
|
+
const SKIP_FILE_EXTENSIONS = ['.lock', '.min.js', '.min.css', '.map', '.bundle.js', '.tfstate', '.tfstate.backup'];
|
|
82
|
+
const SKIP_FILE_NAMES = new Set(['package-lock.json']);
|
|
83
|
+
/**
|
|
84
|
+
* Load .gitignore patterns from the repo root.
|
|
85
|
+
*
|
|
86
|
+
* Negation patterns (`!foo`) are dropped — minimatch does not interpret them
|
|
87
|
+
* as gitignore would, and passing them through causes directory-wide blindness
|
|
88
|
+
* (see server-side fix in ignore-utils.ts).
|
|
89
|
+
*/
|
|
90
|
+
function loadGitignore(repoPath) {
|
|
91
|
+
try {
|
|
92
|
+
const gitignorePath = path.join(repoPath, '.gitignore');
|
|
93
|
+
if (!fs.existsSync(gitignorePath))
|
|
94
|
+
return [];
|
|
95
|
+
const content = fs.readFileSync(gitignorePath, 'utf-8');
|
|
96
|
+
return content
|
|
97
|
+
.split('\n')
|
|
98
|
+
.map((line) => line.trim())
|
|
99
|
+
.filter((line) => line && !line.startsWith('#') && !line.startsWith('!'))
|
|
100
|
+
.map((line) => (line.endsWith('/') ? line.slice(0, -1) : line));
|
|
71
101
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
102
|
+
catch {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function matchesAny(filePath, patterns) {
|
|
107
|
+
return patterns.some((p) => (0, minimatch_1.minimatch)(filePath, p));
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Decide whether a directory or file entry should be excluded from collection.
|
|
111
|
+
* Mirrors server `shouldIgnore` so scanner results stay consistent between
|
|
112
|
+
* client-collected (CLI) and server-collected paths.
|
|
113
|
+
*/
|
|
114
|
+
function shouldIgnore(name, relPath, ignores, userExcludes) {
|
|
115
|
+
if (SKIP_DIRS.has(name) ||
|
|
116
|
+
SKIP_DIR_SUFFIXES.some((s) => name.endsWith(s)) ||
|
|
117
|
+
(name.startsWith('.') &&
|
|
118
|
+
!name.startsWith('.env') &&
|
|
119
|
+
!name.startsWith('.github') &&
|
|
120
|
+
!name.startsWith('.gitlab'))) {
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
if (userExcludes && userExcludes.length > 0) {
|
|
124
|
+
for (const pattern of userExcludes) {
|
|
125
|
+
if (name === pattern ||
|
|
126
|
+
name + '/' === pattern ||
|
|
127
|
+
relPath === pattern ||
|
|
128
|
+
relPath + '/' === pattern) {
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
89
131
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
132
|
+
if (matchesAny(relPath, userExcludes))
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
// .env* files are always scanned, even if listed in .gitignore (common case)
|
|
136
|
+
if (name.startsWith('.env') || name.endsWith('.env'))
|
|
137
|
+
return false;
|
|
138
|
+
for (const pattern of ignores) {
|
|
139
|
+
if (name === pattern ||
|
|
140
|
+
name + '/' === pattern ||
|
|
141
|
+
relPath === pattern ||
|
|
142
|
+
relPath + '/' === pattern) {
|
|
143
|
+
return true;
|
|
94
144
|
}
|
|
95
|
-
files[match] = buffer.toString('utf8');
|
|
96
|
-
count++;
|
|
97
145
|
}
|
|
98
|
-
|
|
146
|
+
if (matchesAny(relPath, ignores))
|
|
147
|
+
return true;
|
|
148
|
+
return false;
|
|
99
149
|
}
|
|
100
150
|
function isBinary(buffer) {
|
|
101
151
|
for (let i = 0; i < Math.min(buffer.length, 1024); i++) {
|
|
@@ -104,3 +154,74 @@ function isBinary(buffer) {
|
|
|
104
154
|
}
|
|
105
155
|
return false;
|
|
106
156
|
}
|
|
157
|
+
function shouldSkipFileByName(name) {
|
|
158
|
+
if (SKIP_FILE_NAMES.has(name))
|
|
159
|
+
return true;
|
|
160
|
+
return SKIP_FILE_EXTENSIONS.some((ext) => name.endsWith(ext));
|
|
161
|
+
}
|
|
162
|
+
async function collectFiles(baseDir, includePatterns, excludePatterns) {
|
|
163
|
+
const repoRoot = path.resolve(baseDir);
|
|
164
|
+
const ignores = loadGitignore(repoRoot);
|
|
165
|
+
const files = {};
|
|
166
|
+
const state = { count: 0, limitReached: false };
|
|
167
|
+
walk(repoRoot, repoRoot, ignores, includePatterns, excludePatterns, files, state);
|
|
168
|
+
return files;
|
|
169
|
+
}
|
|
170
|
+
function walk(dir, repoRoot, ignores, includePatterns, userExcludes, files, state) {
|
|
171
|
+
if (state.limitReached)
|
|
172
|
+
return;
|
|
173
|
+
let entries;
|
|
174
|
+
try {
|
|
175
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
for (const entry of entries) {
|
|
181
|
+
if (state.limitReached)
|
|
182
|
+
return;
|
|
183
|
+
const name = entry.name;
|
|
184
|
+
const fullPath = path.join(dir, name);
|
|
185
|
+
const relPath = path.relative(repoRoot, fullPath);
|
|
186
|
+
if (entry.isDirectory()) {
|
|
187
|
+
if (shouldIgnore(name, relPath, ignores, userExcludes))
|
|
188
|
+
continue;
|
|
189
|
+
walk(fullPath, repoRoot, ignores, includePatterns, userExcludes, files, state);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (!entry.isFile())
|
|
193
|
+
continue;
|
|
194
|
+
if (shouldIgnore(name, relPath, ignores, userExcludes))
|
|
195
|
+
continue;
|
|
196
|
+
if (shouldSkipFileByName(name))
|
|
197
|
+
continue;
|
|
198
|
+
if (includePatterns && includePatterns.length > 0 && !matchesAny(relPath, includePatterns)) {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (state.count >= MAX_TOTAL_FILES) {
|
|
202
|
+
console.warn(`Reached max file limit (${MAX_TOTAL_FILES}). Some files were skipped.`);
|
|
203
|
+
state.limitReached = true;
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
let stats;
|
|
207
|
+
try {
|
|
208
|
+
stats = fs.statSync(fullPath);
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (stats.size > MAX_FILE_SIZE)
|
|
214
|
+
continue;
|
|
215
|
+
let buffer;
|
|
216
|
+
try {
|
|
217
|
+
buffer = fs.readFileSync(fullPath);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (isBinary(buffer))
|
|
223
|
+
continue;
|
|
224
|
+
files[relPath] = buffer.toString('utf8');
|
|
225
|
+
state.count++;
|
|
226
|
+
}
|
|
227
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prodcycle/prodcycle",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Multi-framework policy-as-code compliance scanner for infrastructure and application code.",
|
|
5
5
|
"homepage": "https://docs.prodcycle.com",
|
|
6
6
|
"repository": {
|
|
@@ -34,8 +34,7 @@
|
|
|
34
34
|
"license": "SEE LICENSE IN LICENSE",
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"commander": "^12.0.0",
|
|
37
|
-
"
|
|
38
|
-
"ignore": "^5.3.1"
|
|
37
|
+
"minimatch": "^9.0.3"
|
|
39
38
|
},
|
|
40
39
|
"devDependencies": {
|
|
41
40
|
"@types/node": "^22.0.0",
|