@prodcycle/prodcycle 0.4.2 → 0.6.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/README.md +14 -5
- package/dist/api-client.d.ts +156 -3
- package/dist/api-client.js +471 -33
- package/dist/cli.d.ts +14 -1
- package/dist/cli.js +378 -13
- package/dist/index.d.ts +60 -20
- package/dist/index.js +78 -12
- package/dist/utils/fs.js +117 -4
- package/package.json +2 -1
package/dist/cli.js
CHANGED
|
@@ -34,7 +34,9 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
};
|
|
35
35
|
})();
|
|
36
36
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
|
+
exports.isCiEnvironment = isCiEnvironment;
|
|
37
38
|
const commander_1 = require("commander");
|
|
39
|
+
const child_process_1 = require("child_process");
|
|
38
40
|
const fs = __importStar(require("fs"));
|
|
39
41
|
const path = __importStar(require("path"));
|
|
40
42
|
const index_1 = require("./index");
|
|
@@ -43,6 +45,7 @@ const sarif_1 = require("./formatters/sarif");
|
|
|
43
45
|
const prompt_1 = require("./formatters/prompt");
|
|
44
46
|
const KNOWN_COMMANDS = new Set([
|
|
45
47
|
'scan',
|
|
48
|
+
'scans',
|
|
46
49
|
'gate',
|
|
47
50
|
'hook',
|
|
48
51
|
'init',
|
|
@@ -110,36 +113,116 @@ program
|
|
|
110
113
|
.name('prodcycle')
|
|
111
114
|
.description('Multi-framework policy-as-code compliance scanner for infrastructure and application code.')
|
|
112
115
|
.version(PKG_VERSION);
|
|
116
|
+
/**
|
|
117
|
+
* Detect CI environment via well-known env vars set by the major
|
|
118
|
+
* platforms. When CI is detected, default `--format` flips to `sarif`
|
|
119
|
+
* (so output drops straight into GitHub code scanning / GitLab security
|
|
120
|
+
* dashboards / etc. without extra configuration). Users can still
|
|
121
|
+
* override with `--format table|json|prompt`.
|
|
122
|
+
*
|
|
123
|
+
* The flip is opt-out (set `--format table` explicitly to keep the
|
|
124
|
+
* human-readable output in CI logs). Heuristic, not load-bearing — if
|
|
125
|
+
* we miss a CI platform here the user gets the same default they
|
|
126
|
+
* would have anyway (`table`), they just have to add `--format sarif`
|
|
127
|
+
* by hand.
|
|
128
|
+
*/
|
|
129
|
+
function isCiEnvironment(env = process.env) {
|
|
130
|
+
// Generic `CI`: match any non-empty value. Most platforms set `CI=true`
|
|
131
|
+
// but some (Drone CI, Woodpecker CI, custom Jenkins pipelines) use
|
|
132
|
+
// `CI=1` or another truthy string. Specific platforms below cover the
|
|
133
|
+
// happy path; this is a defense-in-depth fallback so we don't miss
|
|
134
|
+
// edge-case environments.
|
|
135
|
+
return ((env['CI'] != null && env['CI'] !== '') ||
|
|
136
|
+
env['GITHUB_ACTIONS'] === 'true' ||
|
|
137
|
+
env['GITLAB_CI'] === 'true' ||
|
|
138
|
+
env['CIRCLECI'] === 'true' ||
|
|
139
|
+
env['JENKINS_URL'] != null ||
|
|
140
|
+
env['BUILDKITE'] === 'true' ||
|
|
141
|
+
env['TRAVIS'] === 'true' ||
|
|
142
|
+
env['BITBUCKET_BUILD_NUMBER'] != null);
|
|
143
|
+
}
|
|
113
144
|
// ── scan ────────────────────────────────────────────────────────────────────
|
|
114
145
|
program
|
|
115
146
|
.command('scan [repo_path]')
|
|
116
147
|
.description('Scan a repository for compliance violations')
|
|
117
|
-
.
|
|
118
|
-
|
|
119
|
-
|
|
148
|
+
// Default frameworks: all three. The unique value of this scanner is
|
|
149
|
+
// cross-framework evaluation in one pass; defaulting to `soc2` only
|
|
150
|
+
// hid the HIPAA + NIST CSF capability from users who never thought
|
|
151
|
+
// to override the flag. If users need only one framework they can
|
|
152
|
+
// still pass `--framework soc2` explicitly.
|
|
153
|
+
.option('--framework <ids>', 'Comma-separated framework IDs to evaluate', 'soc2,hipaa,nist-csf')
|
|
154
|
+
// Default format: `table` for interactive use, but auto-flipped to
|
|
155
|
+
// `sarif` when CI is detected (see isCiEnvironment above) so GitHub
|
|
156
|
+
// Code Scanning / GitLab dashboards pick the report up without any
|
|
157
|
+
// extra wiring. The CLI's --format flag overrides the auto-flip.
|
|
158
|
+
.option('--format <format>', 'Output format: json, sarif, table, prompt (auto-defaults to sarif in CI)', undefined)
|
|
159
|
+
// Default severity-threshold: `medium`. `low` includes too many
|
|
160
|
+
// tier-3 advisory findings that are typically noise unless the user
|
|
161
|
+
// explicitly opts in; `high` would hide medium-severity weak-crypto
|
|
162
|
+
// findings that ARE actionable. Medium is the right balance for
|
|
163
|
+
// first-time users.
|
|
164
|
+
.option('--severity-threshold <severity>', 'Minimum severity to include in report', 'medium')
|
|
120
165
|
.option('--fail-on <levels>', 'Comma-separated severities that cause non-zero exit', 'critical,high')
|
|
121
166
|
.option('--include <patterns>', 'Comma-separated glob patterns to include')
|
|
122
167
|
.option('--exclude <patterns>', 'Comma-separated glob patterns to exclude')
|
|
123
168
|
.option('--output <file>', 'Write report to file')
|
|
124
169
|
.option('--api-url <url>', 'Compliance API base URL (or PC_API_URL env)')
|
|
125
170
|
.option('--api-key <key>', 'API key for compliance API (or PC_API_KEY env)')
|
|
171
|
+
.option('--async', 'Use the async-validate flow (server returns 202 immediately; CLI polls until COMPLETED). Useful for large scans where holding a connection isn’t practical.')
|
|
172
|
+
.option('--chunked', 'Force the chunked-session flow regardless of payload size. The default already auto-falls-back to chunked when /validate returns 413 with a chunked-endpoint suggestion.')
|
|
173
|
+
.option('--pr <range>', 'Scan only files changed in a git diff range (e.g. "origin/main..HEAD"). Cuts CI scan time on large repos by skipping unchanged files. Requires baseDir to be the git repo root.')
|
|
126
174
|
.action(async (repoPath, opts) => {
|
|
127
175
|
try {
|
|
128
176
|
const target = repoPath ?? '.';
|
|
129
|
-
const frameworks = parseList(opts.framework) ?? ['soc2'];
|
|
177
|
+
const frameworks = parseList(opts.framework) ?? ['soc2', 'hipaa', 'nist-csf'];
|
|
130
178
|
const failOn = parseList(opts.failOn) ?? ['critical', 'high'];
|
|
131
|
-
|
|
132
|
-
|
|
179
|
+
// Format resolution:
|
|
180
|
+
// 1. explicit --format wins
|
|
181
|
+
// 2. otherwise: sarif when CI is detected, table when interactive
|
|
182
|
+
// SARIF in CI lets GitHub code scanning / GitLab security dashboards
|
|
183
|
+
// ingest results with zero extra configuration; table in interactive
|
|
184
|
+
// shells gives the human-readable summary first-time users expect.
|
|
185
|
+
const format = (opts.format ?? (isCiEnvironment() ? 'sarif' : 'table'));
|
|
186
|
+
// --async and --chunked are mutually exclusive; pick the explicit
|
|
187
|
+
// mode if either flag is set, otherwise let `scan()` pick (sync
|
|
188
|
+
// with auto-fallback to chunked on 413).
|
|
189
|
+
let mode = 'sync';
|
|
190
|
+
if (opts.async && opts.chunked) {
|
|
191
|
+
console.error('scan: --async and --chunked are mutually exclusive.');
|
|
192
|
+
process.exit(2);
|
|
193
|
+
}
|
|
194
|
+
if (opts.async)
|
|
195
|
+
mode = 'async';
|
|
196
|
+
else if (opts.chunked)
|
|
197
|
+
mode = 'chunked';
|
|
198
|
+
// --pr: restrict the scan to files in `git diff --name-only <range>`.
|
|
199
|
+
// Empty diff → exit 0 immediately (nothing to scan).
|
|
200
|
+
let include = parseList(opts.include);
|
|
201
|
+
if (opts.pr) {
|
|
202
|
+
const changed = computeChangedFiles(target, opts.pr);
|
|
203
|
+
if (changed.length === 0) {
|
|
204
|
+
console.error(`No files changed in range "${opts.pr}". Nothing to scan.`);
|
|
205
|
+
process.exit(0);
|
|
206
|
+
}
|
|
207
|
+
console.error(`--pr ${opts.pr}: restricting scan to ${changed.length} changed file(s).`);
|
|
208
|
+
// Use the diff list as exact-match include patterns. minimatch treats
|
|
209
|
+
// ordinary paths (no glob chars) as literal matches against relPath.
|
|
210
|
+
include = changed;
|
|
211
|
+
}
|
|
212
|
+
console.error(`Scanning ${path.resolve(target)} for ${frameworks.join(', ')}` +
|
|
213
|
+
(mode === 'sync' ? '' : ` (${mode} mode)`) +
|
|
214
|
+
'...');
|
|
133
215
|
const response = await (0, index_1.scan)({
|
|
134
216
|
repoPath: target,
|
|
135
217
|
frameworks,
|
|
136
218
|
options: {
|
|
137
219
|
severityThreshold: opts.severityThreshold,
|
|
138
220
|
failOn: failOn,
|
|
139
|
-
include
|
|
221
|
+
include,
|
|
140
222
|
exclude: parseList(opts.exclude),
|
|
141
223
|
apiUrl: opts.apiUrl,
|
|
142
224
|
apiKey: opts.apiKey,
|
|
225
|
+
config: { mode },
|
|
143
226
|
},
|
|
144
227
|
});
|
|
145
228
|
writeOutput(renderReport(response, format), opts.output);
|
|
@@ -196,6 +279,54 @@ program
|
|
|
196
279
|
process.exit(2);
|
|
197
280
|
}
|
|
198
281
|
});
|
|
282
|
+
// ── scans ───────────────────────────────────────────────────────────────────
|
|
283
|
+
// Fetch the current status / final result of any scan by ID. Useful with
|
|
284
|
+
// `--async` to resume a poll loop after a CI step boundary, or to inspect
|
|
285
|
+
// a chunked session that was abandoned mid-flight.
|
|
286
|
+
program
|
|
287
|
+
.command('scans <scanId>')
|
|
288
|
+
.description('Get the status + findings of a scan by ID')
|
|
289
|
+
.option('--format <format>', 'Output format: json, sarif, table, prompt', 'json')
|
|
290
|
+
.option('--output <file>', 'Write report to file')
|
|
291
|
+
.option('--api-url <url>', 'Compliance API base URL (or PC_API_URL env)')
|
|
292
|
+
.option('--api-key <key>', 'API key for compliance API (or PC_API_KEY env)')
|
|
293
|
+
.action(async (scanId, opts) => {
|
|
294
|
+
try {
|
|
295
|
+
const format = (opts.format ?? 'json');
|
|
296
|
+
const { ComplianceApiClient } = await Promise.resolve().then(() => __importStar(require('./api-client')));
|
|
297
|
+
const client = new ComplianceApiClient(opts.apiUrl, opts.apiKey);
|
|
298
|
+
const scan = await client.getScan(scanId);
|
|
299
|
+
// Same scannerError / exit-code-2 plumbing as scan() / gate(): a
|
|
300
|
+
// user retrieving a stored scan that failed for scanner reasons
|
|
301
|
+
// must see the same distinction (exit 2, scannerError surfaced).
|
|
302
|
+
const scannerError = scan.scannerError;
|
|
303
|
+
const exitCode = scannerError ? 2 : scan.passed ? 0 : 1;
|
|
304
|
+
const payload = {
|
|
305
|
+
scanId,
|
|
306
|
+
passed: scan.passed,
|
|
307
|
+
status: scan.status ?? 'COMPLETED',
|
|
308
|
+
findings: scan.findings ?? [],
|
|
309
|
+
summary: scan.summary,
|
|
310
|
+
exitCode,
|
|
311
|
+
...(scannerError ? { scannerError } : {}),
|
|
312
|
+
};
|
|
313
|
+
// Use the same renderer as `scan` so format=table/sarif/prompt all work.
|
|
314
|
+
writeOutput(renderReport(payload, format), opts.output);
|
|
315
|
+
if (scannerError)
|
|
316
|
+
(0, index_1.emitScannerErrorWarning)(scannerError);
|
|
317
|
+
// Exit 2 if scan is still in progress — the CLI run shouldn't gate on
|
|
318
|
+
// an indeterminate result.
|
|
319
|
+
if (scan.status === 'IN_PROGRESS') {
|
|
320
|
+
console.error(`Scan ${scanId} is still IN_PROGRESS. Re-run the same command to keep polling, or use 'pc scan --async' to wait for completion.`);
|
|
321
|
+
process.exit(2);
|
|
322
|
+
}
|
|
323
|
+
process.exit(exitCode);
|
|
324
|
+
}
|
|
325
|
+
catch (error) {
|
|
326
|
+
console.error(`✗ Error: ${error.message}`);
|
|
327
|
+
process.exit(2);
|
|
328
|
+
}
|
|
329
|
+
});
|
|
199
330
|
// ── hook ────────────────────────────────────────────────────────────────────
|
|
200
331
|
program
|
|
201
332
|
.command('hook')
|
|
@@ -290,18 +421,22 @@ async function collectHookFiles(filePath) {
|
|
|
290
421
|
// ── init ────────────────────────────────────────────────────────────────────
|
|
291
422
|
program
|
|
292
423
|
.command('init')
|
|
293
|
-
.description('Configure compliance hooks for coding agents')
|
|
424
|
+
.description('Configure compliance hooks for coding agents and/or CI workflows')
|
|
294
425
|
.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.')
|
|
426
|
+
.option('--ci <providers>', 'Comma-separated CI providers to scaffold (github, gitlab, circleci). Use "all" for every provider. Opt-in only \u2014 never auto-detected.')
|
|
295
427
|
.option('--force', 'Overwrite existing compliance hook entries')
|
|
296
428
|
.option('--dir <path>', 'Project directory to configure', '.')
|
|
297
429
|
.action((opts) => {
|
|
298
430
|
try {
|
|
299
431
|
const dir = path.resolve(opts.dir ?? '.');
|
|
300
432
|
const agents = resolveAgents(opts.agent, dir);
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
'
|
|
433
|
+
const ciProviders = resolveCiProviders(opts.ci);
|
|
434
|
+
if (agents.length === 0 && ciProviders.length === 0) {
|
|
435
|
+
console.error('init: nothing to do. ' +
|
|
436
|
+
'Use --agent <name> to configure a coding agent (claude, cursor, codex, ' +
|
|
437
|
+
'opencode, github-copilot, gemini-cli, or "all"), and/or --ci <provider> ' +
|
|
438
|
+
'to scaffold CI workflows (github, gitlab, circleci, or "all"). ' +
|
|
439
|
+
'Without --agent the CLI also auto-detects agents already in use.');
|
|
305
440
|
process.exit(2);
|
|
306
441
|
}
|
|
307
442
|
let anyFailed = false;
|
|
@@ -312,6 +447,12 @@ program
|
|
|
312
447
|
if (result.status === 'failed')
|
|
313
448
|
anyFailed = true;
|
|
314
449
|
}
|
|
450
|
+
for (const provider of ciProviders) {
|
|
451
|
+
const result = configureCiProvider(provider, dir, !!opts.force);
|
|
452
|
+
process.stdout.write(result.message + '\n');
|
|
453
|
+
if (result.status === 'failed')
|
|
454
|
+
anyFailed = true;
|
|
455
|
+
}
|
|
315
456
|
process.exit(anyFailed ? 1 : 0);
|
|
316
457
|
}
|
|
317
458
|
catch (error) {
|
|
@@ -547,6 +688,224 @@ function configureInstructionFile(agent, dir, relPath, force, writtenPaths) {
|
|
|
547
688
|
function escapeRegExp(s) {
|
|
548
689
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
549
690
|
}
|
|
691
|
+
const ALL_CI_PROVIDERS = ['github', 'gitlab', 'circleci'];
|
|
692
|
+
function isCiProvider(name) {
|
|
693
|
+
return ALL_CI_PROVIDERS.includes(name);
|
|
694
|
+
}
|
|
695
|
+
function resolveCiProviders(userChoice) {
|
|
696
|
+
if (!userChoice)
|
|
697
|
+
return [];
|
|
698
|
+
const list = parseList(userChoice) ?? [];
|
|
699
|
+
if (list.length === 1 && list[0] === 'all')
|
|
700
|
+
return ALL_CI_PROVIDERS.slice();
|
|
701
|
+
const valid = [];
|
|
702
|
+
for (const name of list) {
|
|
703
|
+
if (isCiProvider(name))
|
|
704
|
+
valid.push(name);
|
|
705
|
+
else
|
|
706
|
+
console.error(`init: unknown CI provider "${name}" — ignoring`);
|
|
707
|
+
}
|
|
708
|
+
return valid;
|
|
709
|
+
}
|
|
710
|
+
function configureCiProvider(provider, dir, force) {
|
|
711
|
+
switch (provider) {
|
|
712
|
+
case 'github':
|
|
713
|
+
return writeCiFile(provider, dir, path.join('.github', 'workflows', 'prodcycle.yml'), GITHUB_WORKFLOW, force);
|
|
714
|
+
case 'gitlab':
|
|
715
|
+
return writeCiFile(provider, dir, '.gitlab-ci.prodcycle.yml', GITLAB_WORKFLOW, force);
|
|
716
|
+
case 'circleci':
|
|
717
|
+
return writeCiFile(provider, dir, path.join('.circleci', 'prodcycle.yml'), CIRCLECI_WORKFLOW, force);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
function writeCiFile(provider, dir, relPath, content, force) {
|
|
721
|
+
const fullPath = path.join(dir, relPath);
|
|
722
|
+
if (fs.existsSync(fullPath) && !force) {
|
|
723
|
+
return {
|
|
724
|
+
status: 'already',
|
|
725
|
+
message: `[ci:${provider}] ${relPath} already exists. Use --force to overwrite.`,
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
const parent = path.dirname(fullPath);
|
|
729
|
+
if (!fs.existsSync(parent))
|
|
730
|
+
fs.mkdirSync(parent, { recursive: true });
|
|
731
|
+
fs.writeFileSync(fullPath, content);
|
|
732
|
+
// GitHub uses the `prodcycle/actions/compliance` action, which reads
|
|
733
|
+
// its key from `secrets.PRODCYCLE_API_KEY`. GitLab and CircleCI invoke
|
|
734
|
+
// the CLI directly, which reads `PC_API_KEY` from the environment.
|
|
735
|
+
const followup = provider === 'gitlab'
|
|
736
|
+
? `Include it from .gitlab-ci.yml: \`include: '${relPath}'\`. `
|
|
737
|
+
: provider === 'circleci'
|
|
738
|
+
? `Reference it from .circleci/config.yml or merge the contents in. `
|
|
739
|
+
: '';
|
|
740
|
+
const secretName = provider === 'github' ? 'PRODCYCLE_API_KEY' : 'PC_API_KEY';
|
|
741
|
+
return {
|
|
742
|
+
status: 'installed',
|
|
743
|
+
message: `[ci:${provider}] wrote ${fullPath}. ` +
|
|
744
|
+
followup +
|
|
745
|
+
`Set ${secretName} as a secret/variable in your ${provider} project before the first run.`,
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
// GitHub: delegate to the dedicated `prodcycle/actions/compliance` GitHub
|
|
749
|
+
// Action rather than calling the CLI directly. The action handles diff vs
|
|
750
|
+
// full-repo scan automatically (PR events vs push events), posts inline
|
|
751
|
+
// annotations on the PR diff, and writes a summary comment — none of
|
|
752
|
+
// which the CLI's own SARIF output reproduces. See
|
|
753
|
+
// https://github.com/prodcycle/actions for the full input reference.
|
|
754
|
+
const GITHUB_WORKFLOW = `name: Prodcycle Compliance
|
|
755
|
+
|
|
756
|
+
on:
|
|
757
|
+
pull_request:
|
|
758
|
+
push:
|
|
759
|
+
# Update this list to match your repo's default branch (e.g. master,
|
|
760
|
+
# develop). GitHub Actions does not support a dynamic
|
|
761
|
+
# \$default-branch / \${{ github.event.repository.default_branch }}
|
|
762
|
+
# value here, so the branch name has to be literal.
|
|
763
|
+
branches: [main]
|
|
764
|
+
|
|
765
|
+
jobs:
|
|
766
|
+
scan:
|
|
767
|
+
runs-on: ubuntu-latest
|
|
768
|
+
permissions:
|
|
769
|
+
contents: read
|
|
770
|
+
pull-requests: write
|
|
771
|
+
steps:
|
|
772
|
+
- uses: actions/checkout@v4
|
|
773
|
+
with:
|
|
774
|
+
fetch-depth: 0
|
|
775
|
+
- uses: prodcycle/actions/compliance@v2
|
|
776
|
+
with:
|
|
777
|
+
api-key: \${{ secrets.PRODCYCLE_API_KEY }}
|
|
778
|
+
`;
|
|
779
|
+
const GITLAB_WORKFLOW = `# Prodcycle compliance scan. Include from your main .gitlab-ci.yml:
|
|
780
|
+
# include:
|
|
781
|
+
# - local: .gitlab-ci.prodcycle.yml
|
|
782
|
+
#
|
|
783
|
+
# Set PC_API_KEY as a CI/CD variable (Settings → CI/CD → Variables) before
|
|
784
|
+
# the first run. Mark it Masked + Protected.
|
|
785
|
+
|
|
786
|
+
prodcycle:
|
|
787
|
+
stage: test
|
|
788
|
+
image: node:22-alpine
|
|
789
|
+
variables:
|
|
790
|
+
GIT_DEPTH: "0"
|
|
791
|
+
before_script:
|
|
792
|
+
- apk add --no-cache git
|
|
793
|
+
script:
|
|
794
|
+
- |
|
|
795
|
+
if [ "$CI_PIPELINE_SOURCE" = "merge_request_event" ]; then
|
|
796
|
+
git fetch --no-tags origin "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
|
|
797
|
+
npx --yes prodcycle scan . \\
|
|
798
|
+
--pr "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME..HEAD" \\
|
|
799
|
+
--format sarif --output prodcycle.sarif
|
|
800
|
+
else
|
|
801
|
+
npx --yes prodcycle scan . --format sarif --output prodcycle.sarif
|
|
802
|
+
fi
|
|
803
|
+
artifacts:
|
|
804
|
+
when: always
|
|
805
|
+
paths:
|
|
806
|
+
- prodcycle.sarif
|
|
807
|
+
reports:
|
|
808
|
+
sast: prodcycle.sarif
|
|
809
|
+
rules:
|
|
810
|
+
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
811
|
+
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
|
812
|
+
`;
|
|
813
|
+
const CIRCLECI_WORKFLOW = `# Prodcycle compliance scan. To use this, either replace .circleci/config.yml
|
|
814
|
+
# or include it as a continuation/orb. Minimum example:
|
|
815
|
+
#
|
|
816
|
+
# version: 2.1
|
|
817
|
+
# workflows:
|
|
818
|
+
# compliance:
|
|
819
|
+
# jobs:
|
|
820
|
+
# - prodcycle-scan
|
|
821
|
+
#
|
|
822
|
+
# Set PC_API_KEY as a project environment variable in CircleCI before the
|
|
823
|
+
# first run.
|
|
824
|
+
#
|
|
825
|
+
# CircleCI does not expose the PR target branch as a built-in env var
|
|
826
|
+
# (\`CIRCLE_BASE_BRANCH\` does not exist; see
|
|
827
|
+
# https://circleci.com/docs/reference/variables/), so to scope PR scans
|
|
828
|
+
# to changed files only, set a project-level env var \`PRODCYCLE_PR_BASE\`
|
|
829
|
+
# to the branch your PRs target (e.g. \`main\`, \`develop\`). When unset,
|
|
830
|
+
# this template runs a full-repo scan.
|
|
831
|
+
|
|
832
|
+
version: 2.1
|
|
833
|
+
jobs:
|
|
834
|
+
prodcycle-scan:
|
|
835
|
+
docker:
|
|
836
|
+
- image: cimg/node:22.0
|
|
837
|
+
steps:
|
|
838
|
+
- checkout
|
|
839
|
+
- run:
|
|
840
|
+
name: Run Prodcycle compliance scan
|
|
841
|
+
command: |
|
|
842
|
+
if [ -n "\${PRODCYCLE_PR_BASE:-}" ] && [ -n "\${CIRCLE_PULL_REQUEST:-}" ]; then
|
|
843
|
+
git fetch --no-tags origin "$PRODCYCLE_PR_BASE"
|
|
844
|
+
npx --yes prodcycle scan . \\
|
|
845
|
+
--pr "origin/$PRODCYCLE_PR_BASE..HEAD" \\
|
|
846
|
+
--format sarif --output prodcycle.sarif
|
|
847
|
+
else
|
|
848
|
+
npx --yes prodcycle scan . --format sarif --output prodcycle.sarif
|
|
849
|
+
fi
|
|
850
|
+
# \`when: always\` so the SARIF artifact uploads even when the scan
|
|
851
|
+
# exits non-zero — compliance scanners exit 1 when findings exist,
|
|
852
|
+
# which is precisely the case where you want the report preserved.
|
|
853
|
+
- store_artifacts:
|
|
854
|
+
path: prodcycle.sarif
|
|
855
|
+
destination: prodcycle-sarif
|
|
856
|
+
when: always
|
|
857
|
+
|
|
858
|
+
workflows:
|
|
859
|
+
compliance:
|
|
860
|
+
jobs:
|
|
861
|
+
- prodcycle-scan
|
|
862
|
+
`;
|
|
863
|
+
/**
|
|
864
|
+
* Compute the list of files changed in a git diff range, relative to repo root.
|
|
865
|
+
* Filters to ACMR (Added/Copied/Modified/Renamed) so deleted files don't get
|
|
866
|
+
* scanned (they're not on disk anymore, and walk() would skip them anyway).
|
|
867
|
+
*
|
|
868
|
+
* Errors handled explicitly:
|
|
869
|
+
* - `ENOENT` (git not in PATH) → actionable "git executable not found"
|
|
870
|
+
* - `ETIMEDOUT` (git stalled — credential helper / auth prompt / etc.)
|
|
871
|
+
* → fail fast with a 30s timeout so CI jobs don't hang
|
|
872
|
+
* - non-zero exit → forward git's stderr so the user can see e.g. the
|
|
873
|
+
* "fatal: bad revision" message and fix the range argument
|
|
874
|
+
*
|
|
875
|
+
* Output paths are normalised to the platform separator: git emits POSIX
|
|
876
|
+
* forward-slashes always, but the file walker on Windows produces
|
|
877
|
+
* back-slashed `relPath` values. Without this conversion the literal
|
|
878
|
+
* minimatch comparison silently excludes every changed file on Windows.
|
|
879
|
+
*/
|
|
880
|
+
const GIT_DIFF_TIMEOUT_MS = 30_000;
|
|
881
|
+
function computeChangedFiles(repoPath, range) {
|
|
882
|
+
let stdout;
|
|
883
|
+
try {
|
|
884
|
+
stdout = (0, child_process_1.execFileSync)('git', ['-C', repoPath, 'diff', '--name-only', '--diff-filter=ACMR', range], {
|
|
885
|
+
encoding: 'utf8',
|
|
886
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
887
|
+
timeout: GIT_DIFF_TIMEOUT_MS,
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
catch (e) {
|
|
891
|
+
if (e?.code === 'ENOENT') {
|
|
892
|
+
console.error('--pr: git executable not found in PATH');
|
|
893
|
+
process.exit(2);
|
|
894
|
+
}
|
|
895
|
+
if (e?.code === 'ETIMEDOUT' || e?.signal === 'SIGTERM') {
|
|
896
|
+
console.error(`--pr: git diff timed out after ${GIT_DIFF_TIMEOUT_MS}ms (range "${range}"). ` +
|
|
897
|
+
'Check that the range does not require network access or credentials.');
|
|
898
|
+
process.exit(2);
|
|
899
|
+
}
|
|
900
|
+
const stderr = e?.stderr?.toString?.() ?? e?.message ?? 'unknown error';
|
|
901
|
+
console.error(`--pr: git diff failed for range "${range}": ${stderr.trim()}`);
|
|
902
|
+
process.exit(2);
|
|
903
|
+
}
|
|
904
|
+
return stdout
|
|
905
|
+
.split('\n')
|
|
906
|
+
.map((s) => s.trim().split('/').join(path.sep))
|
|
907
|
+
.filter(Boolean);
|
|
908
|
+
}
|
|
550
909
|
function readStdin() {
|
|
551
910
|
return new Promise((resolve, reject) => {
|
|
552
911
|
if (process.stdin.isTTY) {
|
|
@@ -559,4 +918,10 @@ function readStdin() {
|
|
|
559
918
|
process.stdin.on('error', reject);
|
|
560
919
|
});
|
|
561
920
|
}
|
|
562
|
-
|
|
921
|
+
// Only auto-parse when invoked as a script (i.e. via the `prodcycle`
|
|
922
|
+
// bin entry). Importing this module from tests must NOT execute the
|
|
923
|
+
// CLI — otherwise `node --test` triggers a real `program.parse` and
|
|
924
|
+
// fails before the test cases can run.
|
|
925
|
+
if (require.main === module) {
|
|
926
|
+
program.parse(injectScanDefault(process.argv));
|
|
927
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,35 +1,75 @@
|
|
|
1
|
-
import { ScanOptions, GateOptions } from './api-client';
|
|
1
|
+
import { ScanOptions, GateOptions, BackfillError } from './api-client';
|
|
2
2
|
export * from './api-client';
|
|
3
3
|
export * from './formatters/table';
|
|
4
4
|
export * from './formatters/prompt';
|
|
5
5
|
export * from './formatters/sarif';
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
7
|
+
* Set when the server-side scanner threw and the API was configured to
|
|
8
|
+
* fail closed (the default). When this is present, callers MUST treat
|
|
9
|
+
* `passed: false` as "scanner unavailable — cannot certify compliance"
|
|
10
|
+
* rather than "code is dirty." Mirrors the API's `ScannerErrorInfo`
|
|
11
|
+
* shape; see `packages/compliance-code-scanner/api/src/domain/services/
|
|
12
|
+
* compliance-scan.service.ts` (`ScannerErrorInfo`) for the field
|
|
13
|
+
* contract.
|
|
14
|
+
*
|
|
15
|
+
* Without this surfaced to the CLI's --output JSON, a benchmark or CI
|
|
16
|
+
* report shows `passed: false, findings: []` and the user can't tell
|
|
17
|
+
* whether the code passed (no findings, all clean) from whether the
|
|
18
|
+
* scanner failed (no findings because nothing got evaluated).
|
|
19
|
+
*/
|
|
20
|
+
export interface ScannerError {
|
|
21
|
+
code: 'SCANNER_GATE_THREW';
|
|
22
|
+
message: string;
|
|
23
|
+
errorClass?: string;
|
|
24
|
+
errorCode?: string;
|
|
25
|
+
}
|
|
26
|
+
interface ScanReturn {
|
|
27
|
+
scanId?: string;
|
|
28
|
+
passed: boolean;
|
|
29
|
+
exitCode: number;
|
|
30
|
+
findings: unknown[];
|
|
31
|
+
report: unknown;
|
|
32
|
+
summary: unknown;
|
|
33
|
+
scannerError?: ScannerError;
|
|
34
|
+
/**
|
|
35
|
+
* Set when `validateChunked`'s findings-backfill GET failed. The summary
|
|
36
|
+
* still reflects the real finding count, but the structured findings are
|
|
37
|
+
* unavailable in this response. Callers should retry via `prodcycle scans
|
|
38
|
+
* <scanId>` to recover them. SARIF/JSON consumers branch on this to flag
|
|
39
|
+
* the result as incomplete rather than mistakenly clean.
|
|
40
|
+
*/
|
|
41
|
+
backfillError?: BackfillError;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Format and write the scanner-error warning to stderr. Centralized so the
|
|
45
|
+
* wording stays consistent across `scan()`, `gate()`, and the `scans <id>`
|
|
46
|
+
* CLI subcommand.
|
|
47
|
+
*/
|
|
48
|
+
export declare function emitScannerErrorWarning(scannerError: ScannerError): void;
|
|
49
|
+
/**
|
|
50
|
+
* Scan a repository by collecting files and sending them to the API.
|
|
51
|
+
*
|
|
52
|
+
* Modes (selectable via `options.config`):
|
|
53
|
+
* - default: synchronous validate; auto-falls-back to chunked sessions
|
|
54
|
+
* if the server returns 413 with `suggestedEndpoint=/v1/compliance/scans`
|
|
55
|
+
* - `mode: 'async'`: kicks off a 202 async-validate and polls until
|
|
56
|
+
* terminal (returns same shape as default)
|
|
57
|
+
* - `mode: 'chunked'`: explicit chunked-session flow regardless of size
|
|
8
58
|
*/
|
|
9
59
|
export declare function scan(params: {
|
|
10
60
|
repoPath: string;
|
|
11
61
|
frameworks?: string[];
|
|
12
62
|
options?: ScanOptions;
|
|
13
|
-
}): Promise<
|
|
14
|
-
passed: boolean;
|
|
15
|
-
exitCode: number;
|
|
16
|
-
findings: never[];
|
|
17
|
-
report: null;
|
|
18
|
-
summary?: undefined;
|
|
19
|
-
} | {
|
|
20
|
-
passed: any;
|
|
21
|
-
exitCode: number;
|
|
22
|
-
findings: any;
|
|
23
|
-
report: any;
|
|
24
|
-
summary: any;
|
|
25
|
-
}>;
|
|
63
|
+
}): Promise<ScanReturn>;
|
|
26
64
|
/**
|
|
27
|
-
* Gate code strings directly without writing to disk
|
|
65
|
+
* Gate code strings directly without writing to disk (low-latency hook
|
|
66
|
+
* endpoint, used by coding-agent post-edit hooks).
|
|
28
67
|
*/
|
|
29
68
|
export declare function gate(options: GateOptions): Promise<{
|
|
30
|
-
|
|
69
|
+
scannerError?: ScannerError | undefined;
|
|
70
|
+
passed: boolean;
|
|
31
71
|
exitCode: number;
|
|
32
|
-
findings:
|
|
33
|
-
prompt:
|
|
34
|
-
summary:
|
|
72
|
+
findings: unknown[];
|
|
73
|
+
prompt: string | undefined;
|
|
74
|
+
summary: unknown;
|
|
35
75
|
}>;
|