@prodcycle/prodcycle 0.2.1 → 0.3.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 +334 -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.3.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,107 @@ 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;
|
|
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);
|
|
181
|
+
process.exit(response.exitCode);
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
console.error(`\u2717 Error: ${error.message}`);
|
|
185
|
+
process.exit(2);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
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;
|
|
102
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);
|
|
103
216
|
process.exit(response.exitCode);
|
|
104
217
|
}
|
|
105
218
|
catch (error) {
|
|
@@ -107,4 +220,186 @@ program
|
|
|
107
220
|
process.exit(2);
|
|
108
221
|
}
|
|
109
222
|
});
|
|
110
|
-
|
|
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). 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 claude (or cursor) to configure explicitly.');
|
|
293
|
+
process.exit(2);
|
|
294
|
+
}
|
|
295
|
+
let anyFailed = false;
|
|
296
|
+
for (const agent of agents) {
|
|
297
|
+
const result = configureAgent(agent, dir, !!opts.force);
|
|
298
|
+
process.stdout.write(result.message + '\n');
|
|
299
|
+
if (result.status === 'failed')
|
|
300
|
+
anyFailed = true;
|
|
301
|
+
}
|
|
302
|
+
process.exit(anyFailed ? 1 : 0);
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
console.error(`\u2717 Error: ${error.message}`);
|
|
306
|
+
process.exit(2);
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
function resolveAgents(userChoice, dir) {
|
|
310
|
+
if (userChoice) {
|
|
311
|
+
const list = parseList(userChoice) ?? [];
|
|
312
|
+
const valid = [];
|
|
313
|
+
for (const name of list) {
|
|
314
|
+
if (name === 'claude' || name === 'cursor')
|
|
315
|
+
valid.push(name);
|
|
316
|
+
else
|
|
317
|
+
console.error(`init: unknown agent "${name}" — ignoring`);
|
|
318
|
+
}
|
|
319
|
+
return valid;
|
|
320
|
+
}
|
|
321
|
+
// Auto-detect
|
|
322
|
+
const detected = [];
|
|
323
|
+
if (fs.existsSync(path.join(dir, '.claude')))
|
|
324
|
+
detected.push('claude');
|
|
325
|
+
if (fs.existsSync(path.join(dir, '.cursor')))
|
|
326
|
+
detected.push('cursor');
|
|
327
|
+
return detected;
|
|
328
|
+
}
|
|
329
|
+
function configureAgent(agent, dir, force) {
|
|
330
|
+
switch (agent) {
|
|
331
|
+
case 'claude':
|
|
332
|
+
return configureClaudeCode(dir, force);
|
|
333
|
+
case 'cursor':
|
|
334
|
+
return {
|
|
335
|
+
status: 'failed',
|
|
336
|
+
message: '[cursor] skipped — Cursor does not currently expose a post-edit hook mechanism.\n' +
|
|
337
|
+
' Add a `.cursor/rules` entry pointing reviewers at `prodcycle scan .` until hook support lands.',
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
const CLAUDE_MATCHER = 'Write|Edit|MultiEdit';
|
|
342
|
+
const CLAUDE_COMMAND = 'prodcycle hook';
|
|
343
|
+
function configureClaudeCode(dir, force) {
|
|
344
|
+
const claudeDir = path.join(dir, '.claude');
|
|
345
|
+
const settingsPath = path.join(claudeDir, 'settings.json');
|
|
346
|
+
let settings = {};
|
|
347
|
+
if (fs.existsSync(settingsPath)) {
|
|
348
|
+
try {
|
|
349
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
350
|
+
if (typeof settings !== 'object' || settings === null || Array.isArray(settings)) {
|
|
351
|
+
return {
|
|
352
|
+
status: 'failed',
|
|
353
|
+
message: `[claude] ${settingsPath} is not a JSON object — refusing to overwrite. Fix the file manually.`,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
catch (e) {
|
|
358
|
+
return {
|
|
359
|
+
status: 'failed',
|
|
360
|
+
message: `[claude] could not parse ${settingsPath}: ${e.message}. Fix the file manually.`,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
const hooks = (settings.hooks ??= {});
|
|
365
|
+
const postToolUse = (hooks.PostToolUse ??= []);
|
|
366
|
+
// Look for an existing prodcycle entry
|
|
367
|
+
const existing = postToolUse.find((b) => b.hooks?.some((h) => h.type === 'command' && h.command.trim().startsWith('prodcycle hook')));
|
|
368
|
+
if (existing && !force) {
|
|
369
|
+
return {
|
|
370
|
+
status: 'already',
|
|
371
|
+
message: `[claude] PostToolUse hook for prodcycle already present in ${settingsPath}. Use --force to rewrite.`,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
if (existing && force) {
|
|
375
|
+
// Replace in place — preserve the matcher, rewrite the command to the canonical form
|
|
376
|
+
existing.matcher = CLAUDE_MATCHER;
|
|
377
|
+
existing.hooks = [{ type: 'command', command: CLAUDE_COMMAND }];
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
postToolUse.push({
|
|
381
|
+
matcher: CLAUDE_MATCHER,
|
|
382
|
+
hooks: [{ type: 'command', command: CLAUDE_COMMAND }],
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
if (!fs.existsSync(claudeDir))
|
|
386
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
387
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
388
|
+
return {
|
|
389
|
+
status: 'installed',
|
|
390
|
+
message: `[claude] wrote PostToolUse hook to ${settingsPath}. Requires PC_API_KEY in the environment when Claude Code runs.`,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
function readStdin() {
|
|
394
|
+
return new Promise((resolve, reject) => {
|
|
395
|
+
if (process.stdin.isTTY) {
|
|
396
|
+
resolve('');
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
const chunks = [];
|
|
400
|
+
process.stdin.on('data', (c) => chunks.push(c));
|
|
401
|
+
process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
402
|
+
process.stdin.on('error', reject);
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
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.3.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",
|