feat(ci): add CI/CD integration — --fail-on, --compact, pipeline templates

Add threshold-based exit codes (--fail-on <severity>) and compact
output mode (--compact) to scan-orchestrator and CLI. Pipeline
templates for GitHub Actions, Azure DevOps, GitLab CI with SARIF
upload. CI/CD guide with Schrems II/NSM compliance documentation.
npm publish preparation (files whitelist, .npmignore). Policy ci
section for distributable CI defaults. Version 6.1.0.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-04-10 14:59:05 +02:00
commit 2c33e9cc64
15 changed files with 599 additions and 17 deletions

View file

@ -46,6 +46,10 @@ const DEFAULT_POLICY = Object.freeze({
log_path: null,
events: ['trifecta', 'injection', 'secrets', 'destructive'],
},
ci: {
failOn: null,
compact: false,
},
});
// Cache loaded policy per project root

View file

@ -12,6 +12,9 @@ import { discoverFiles } from './lib/file-discovery.mjs';
import { envelope, resetCounter } from './lib/output.mjs';
import { saveBaseline, diffAgainstBaseline, extractFindings } from './lib/diff-engine.mjs';
import { toSARIF } from './lib/sarif-formatter.mjs';
import { loadPolicy } from './lib/policy-loader.mjs';
const FAIL_ON_LEVELS = ['critical', 'high', 'medium', 'low'];
// ---------------------------------------------------------------------------
// .llm-security-ignore support
@ -123,7 +126,7 @@ const SCANNERS = [
// CLI arg parsing — supports --log-file <path>
// ---------------------------------------------------------------------------
function parseArgs(argv) {
const args = { target: null, logFile: null, outputFile: null, baseline: false, saveBaseline: false, format: 'json' };
const args = { target: null, logFile: null, outputFile: null, baseline: false, saveBaseline: false, format: 'json', failOn: null, compact: false };
for (let i = 2; i < argv.length; i++) {
if (argv[i] === '--log-file' && argv[i + 1]) {
args.logFile = argv[++i];
@ -135,6 +138,10 @@ function parseArgs(argv) {
args.baseline = true;
} else if (argv[i] === '--save-baseline') {
args.saveBaseline = true;
} else if (argv[i] === '--fail-on' && argv[i + 1]) {
args.failOn = argv[++i].toLowerCase();
} else if (argv[i] === '--compact') {
args.compact = true;
} else if (!args.target) {
args.target = argv[i];
}
@ -144,6 +151,25 @@ function parseArgs(argv) {
async function main() {
const args = parseArgs(process.argv);
// Policy fallback for CI settings (CLI args take precedence)
try {
const policyRoot = args.target ? resolve(args.target) : process.cwd();
const policy = loadPolicy(policyRoot);
if (args.failOn === null && policy.ci && policy.ci.failOn) {
args.failOn = policy.ci.failOn;
}
if (!args.compact && policy.ci && policy.ci.compact) {
args.compact = true;
}
} catch { /* policy loading is best-effort */ }
// Validate --fail-on value
if (args.failOn !== null && !FAIL_ON_LEVELS.includes(args.failOn)) {
console.error(`--fail-on must be one of: ${FAIL_ON_LEVELS.join(', ')} (got: ${args.failOn})`);
process.exit(1);
}
if (!args.target) {
console.error('Usage: node scan-orchestrator.mjs <target-path> [--log-file <path>]');
process.exit(1);
@ -254,8 +280,28 @@ async function main() {
if (args.outputFile) {
writeFileSync(args.outputFile, jsonStr);
output.output_file = args.outputFile;
// Stdout gets only the compact aggregate (keeps caller context small)
if (args.compact) {
for (const r of Object.values(results)) {
for (const f of r.findings) {
const loc = f.file ? ` (${f.file}${f.line ? ':' + f.line : ''})` : '';
process.stderr.write(`[${f.severity.toUpperCase()}] ${f.scanner}: ${f.title}${loc}\n`);
}
}
}
process.stdout.write(JSON.stringify({ aggregate: output.aggregate, output_file: args.outputFile }) + '\n');
} else if (args.compact && args.format === 'json') {
for (const r of Object.values(results)) {
for (const f of r.findings) {
const loc = f.file ? ` (${f.file}${f.line ? ':' + f.line : ''})` : '';
process.stdout.write(`[${f.severity.toUpperCase()}] ${f.scanner}: ${f.title}${loc}\n`);
}
}
const a = output.aggregate;
process.stdout.write(
`---\nVerdict: ${a.verdict} | Risk: ${a.risk_score}/100 | ` +
`Findings: ${a.total_findings} (${a.counts.critical}C ${a.counts.high}H ${a.counts.medium}M ${a.counts.low}L ${a.counts.info}I) | ` +
`Duration: ${totalDuration}ms\n`
);
} else {
process.stdout.write(jsonStr);
}
@ -271,11 +317,18 @@ async function main() {
`[deep-scan] Duration: ${totalDuration}ms\n`
);
// Exit code based on verdict — use exitCode instead of exit() to allow
// stdout pipe buffers to drain fully (process.exit() truncates >64KB on macOS)
if (agg.verdict === 'BLOCK') process.exitCode = 2;
else if (agg.verdict === 'WARNING') process.exitCode = 1;
else process.exitCode = 0;
// Exit code — use exitCode instead of exit() to allow stdout pipe buffers
// to drain fully (process.exit() truncates >64KB on macOS)
if (args.failOn !== null) {
const threshold = FAIL_ON_LEVELS.indexOf(args.failOn);
const exceeded = FAIL_ON_LEVELS.slice(0, threshold + 1)
.some(sev => (agg.counts[sev] || 0) > 0);
process.exitCode = exceeded ? 1 : 0;
} else {
if (agg.verdict === 'BLOCK') process.exitCode = 2;
else if (agg.verdict === 'WARNING') process.exitCode = 1;
else process.exitCode = 0;
}
}
main().catch(err => {