osv-security-cli 0.1.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.
@@ -0,0 +1,1268 @@
1
+ #!/usr/bin/env node
2
+
3
+ // bin/osv-security.ts
4
+ import { Command } from "commander";
5
+ import { writeFile } from "fs/promises";
6
+ import { resolve as resolve2 } from "path";
7
+
8
+ // src/config/loader.ts
9
+ import { readFile } from "fs/promises";
10
+ import { resolve } from "path";
11
+ import { parse } from "yaml";
12
+
13
+ // src/config/schema.ts
14
+ import { z } from "zod";
15
+ var ProtectedPackageSchema = z.object({
16
+ package: z.string(),
17
+ constraint: z.string(),
18
+ reason: z.string()
19
+ });
20
+ var RuntimeConfigSchema = z.object({
21
+ php: z.string(),
22
+ laravel: z.string(),
23
+ node: z.string(),
24
+ package_manager_php: z.string(),
25
+ package_manager_js: z.string(),
26
+ execution: z.enum(["docker", "local"]),
27
+ docker_service: z.string(),
28
+ test_command: z.string(),
29
+ build_commands: z.object({
30
+ frontend: z.string(),
31
+ backend: z.string()
32
+ })
33
+ });
34
+ var SafeUpdatePolicySchema = z.object({
35
+ allow_patch_and_minor_within_constraints: z.boolean(),
36
+ require_authorization_for_constraint_change: z.boolean(),
37
+ authorization_format: z.string()
38
+ });
39
+ var ProjectConfigSchema = z.object({
40
+ project: z.object({
41
+ name: z.string(),
42
+ client: z.string()
43
+ }),
44
+ runtime: RuntimeConfigSchema,
45
+ protected_packages: z.object({
46
+ composer: z.array(ProtectedPackageSchema),
47
+ npm: z.array(ProtectedPackageSchema)
48
+ }),
49
+ safe_update_policy: SafeUpdatePolicySchema,
50
+ conflict_resolution: z.string()
51
+ });
52
+
53
+ // src/utils/errors.ts
54
+ var ConfigLoadError = class extends Error {
55
+ constructor(message, path) {
56
+ super(message);
57
+ this.path = path;
58
+ this.name = "ConfigLoadError";
59
+ }
60
+ };
61
+ var EnvironmentError = class extends Error {
62
+ constructor(message) {
63
+ super(message);
64
+ this.name = "EnvironmentError";
65
+ }
66
+ };
67
+ var PhaseError = class extends Error {
68
+ constructor(message, phase, cause) {
69
+ super(message);
70
+ this.phase = phase;
71
+ this.cause = cause;
72
+ this.name = "PhaseError";
73
+ }
74
+ };
75
+ var GateValidationError = class extends Error {
76
+ constructor(message, gate, errors) {
77
+ super(message);
78
+ this.gate = gate;
79
+ this.errors = errors;
80
+ this.name = "GateValidationError";
81
+ }
82
+ };
83
+
84
+ // src/config/loader.ts
85
+ var DEFAULT_CONFIG_PATH = ".github/agents/project-config.yml";
86
+ async function loadConfig(configPath, cwd = process.cwd()) {
87
+ const absolutePath = resolve(cwd, configPath);
88
+ let raw;
89
+ try {
90
+ raw = await readFile(absolutePath, "utf-8");
91
+ } catch (err) {
92
+ throw new ConfigLoadError(
93
+ `Cannot read config file: ${absolutePath}`,
94
+ absolutePath
95
+ );
96
+ }
97
+ let parsed;
98
+ try {
99
+ parsed = parse(raw);
100
+ } catch (err) {
101
+ throw new ConfigLoadError(
102
+ `Invalid YAML in config file: ${absolutePath}`,
103
+ absolutePath
104
+ );
105
+ }
106
+ const result = ProjectConfigSchema.safeParse(parsed);
107
+ if (!result.success) {
108
+ const issues = result.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`).join("\n");
109
+ throw new ConfigLoadError(
110
+ `Config validation failed in ${absolutePath}:
111
+ ${issues}`,
112
+ absolutePath
113
+ );
114
+ }
115
+ return result.data;
116
+ }
117
+
118
+ // src/config/generator.ts
119
+ import { stringify } from "yaml";
120
+ function generateConfigYaml(opts = {}) {
121
+ const config = {
122
+ project: {
123
+ name: opts.projectName ?? "My Laravel Project",
124
+ client: opts.client ?? "Client Name"
125
+ },
126
+ runtime: {
127
+ php: opts.phpVersion ?? "8.2",
128
+ laravel: opts.laravelVersion ?? "10.x",
129
+ node: opts.nodeVersion ?? "20.x",
130
+ package_manager_php: "composer",
131
+ package_manager_js: "npm",
132
+ execution: opts.execution ?? "docker",
133
+ docker_service: opts.dockerService ?? "app",
134
+ test_command: opts.testCommand ?? "php artisan test --compact",
135
+ build_commands: {
136
+ frontend: opts.frontendBuildCommand ?? "npm run development-frontend",
137
+ backend: opts.backendBuildCommand ?? "npm run development-backend"
138
+ }
139
+ },
140
+ protected_packages: {
141
+ composer: [
142
+ {
143
+ package: "laravel/framework",
144
+ constraint: "^10.0",
145
+ reason: "Major upgrade to Laravel 11 requires a dedicated project"
146
+ },
147
+ {
148
+ package: "livewire/livewire",
149
+ constraint: "^2.12",
150
+ reason: "Livewire 3 has breaking API changes; do not migrate without a project"
151
+ }
152
+ ],
153
+ npm: [
154
+ {
155
+ package: "alpinejs",
156
+ constraint: "^3.10.2",
157
+ reason: "Alpine v4 may have breaking syntax changes"
158
+ },
159
+ {
160
+ package: "tailwindcss",
161
+ constraint: "^3.3.3",
162
+ reason: "Tailwind v4 has breaking config and migration requirements"
163
+ }
164
+ ]
165
+ },
166
+ safe_update_policy: {
167
+ allow_patch_and_minor_within_constraints: true,
168
+ require_authorization_for_constraint_change: true,
169
+ authorization_format: "sim, confirmo breaking changes para [vendor/pacote]"
170
+ },
171
+ conflict_resolution: "stop_and_ask"
172
+ };
173
+ const header = [
174
+ "# OSV Security CLI \u2014 project configuration",
175
+ "# Generated by: osv-security init",
176
+ "# Documentation: https://github.com/google/osv-scanner",
177
+ "#",
178
+ "# - protected_packages: packages that must never be auto-upgraded beyond their constraint",
179
+ "# - safe_update_policy: patch/minor within constraints are auto-safe; constraint changes require authorization",
180
+ ""
181
+ ].join("\n");
182
+ return header + stringify(config, { lineWidth: 0 });
183
+ }
184
+
185
+ // src/executor/local-executor.ts
186
+ import { execa } from "execa";
187
+ var LocalExecutor = class {
188
+ dryRun;
189
+ environment = "local";
190
+ constructor(options = {}) {
191
+ this.dryRun = options.dryRun ?? false;
192
+ }
193
+ async run(command, options = {}) {
194
+ if (this.dryRun) {
195
+ return {
196
+ stdout: "",
197
+ stderr: "",
198
+ exitCode: 0,
199
+ command,
200
+ dryRun: true
201
+ };
202
+ }
203
+ try {
204
+ const result = await execa(command, {
205
+ shell: true,
206
+ cwd: options.cwd,
207
+ timeout: options.timeout,
208
+ env: options.env ? { ...process.env, ...options.env } : process.env,
209
+ reject: false
210
+ });
211
+ return {
212
+ stdout: result.stdout ?? "",
213
+ stderr: result.stderr ?? "",
214
+ exitCode: result.exitCode ?? 1,
215
+ command,
216
+ dryRun: false
217
+ };
218
+ } catch (err) {
219
+ return {
220
+ stdout: "",
221
+ stderr: err instanceof Error ? err.message : String(err),
222
+ exitCode: 1,
223
+ command,
224
+ dryRun: false
225
+ };
226
+ }
227
+ }
228
+ };
229
+
230
+ // src/executor/docker-executor.ts
231
+ import { execa as execa2 } from "execa";
232
+ var DockerExecutor = class {
233
+ constructor(service, options = {}) {
234
+ this.service = service;
235
+ this.dryRun = options.dryRun ?? false;
236
+ }
237
+ dryRun;
238
+ environment = "docker";
239
+ async run(command, options = {}) {
240
+ const dockerCommand = `docker-compose exec -T ${this.service} sh -c "${command.replace(/"/g, '\\"')}"`;
241
+ if (this.dryRun) {
242
+ return {
243
+ stdout: "",
244
+ stderr: "",
245
+ exitCode: 0,
246
+ command: dockerCommand,
247
+ dryRun: true
248
+ };
249
+ }
250
+ try {
251
+ const result = await execa2(dockerCommand, {
252
+ shell: true,
253
+ cwd: options.cwd,
254
+ timeout: options.timeout,
255
+ env: options.env ? { ...process.env, ...options.env } : process.env,
256
+ reject: false
257
+ });
258
+ return {
259
+ stdout: result.stdout ?? "",
260
+ stderr: result.stderr ?? "",
261
+ exitCode: result.exitCode ?? 1,
262
+ command: dockerCommand,
263
+ dryRun: false
264
+ };
265
+ } catch (err) {
266
+ return {
267
+ stdout: "",
268
+ stderr: err instanceof Error ? err.message : String(err),
269
+ exitCode: 1,
270
+ command: dockerCommand,
271
+ dryRun: false
272
+ };
273
+ }
274
+ }
275
+ };
276
+
277
+ // src/utils/logger.ts
278
+ var LEVELS = {
279
+ debug: 0,
280
+ info: 1,
281
+ warn: 2,
282
+ error: 3
283
+ };
284
+ var currentLevel = "info";
285
+ function setLogLevel(level) {
286
+ currentLevel = level;
287
+ }
288
+ function shouldLog(level) {
289
+ return LEVELS[level] >= LEVELS[currentLevel];
290
+ }
291
+ function format(level, message) {
292
+ const prefix = {
293
+ debug: "[DEBUG]",
294
+ info: "[INFO] ",
295
+ warn: "[WARN] ",
296
+ error: "[ERROR]"
297
+ };
298
+ return `${prefix[level]} ${message}`;
299
+ }
300
+ var logger = {
301
+ debug(message) {
302
+ if (shouldLog("debug")) process.stderr.write(format("debug", message) + "\n");
303
+ },
304
+ info(message) {
305
+ if (shouldLog("info")) process.stderr.write(format("info", message) + "\n");
306
+ },
307
+ warn(message) {
308
+ if (shouldLog("warn")) process.stderr.write(format("warn", message) + "\n");
309
+ },
310
+ error(message) {
311
+ if (shouldLog("error")) process.stderr.write(format("error", message) + "\n");
312
+ }
313
+ };
314
+
315
+ // src/environment/detector.ts
316
+ async function detectEnvironment(preferredEnv, dockerService, cwd, dryRun = false) {
317
+ if (preferredEnv === "local") {
318
+ logger.debug("Using local execution (configured preference)");
319
+ return new LocalExecutor({ dryRun });
320
+ }
321
+ const probe = new LocalExecutor();
322
+ const result = await probe.run(
323
+ `docker-compose ps ${dockerService} 2>/dev/null | grep -c "Up"`,
324
+ { cwd }
325
+ );
326
+ if (result.exitCode === 0 && parseInt(result.stdout.trim(), 10) > 0) {
327
+ logger.debug(`Using Docker execution (service: ${dockerService})`);
328
+ return new DockerExecutor(dockerService, { dryRun });
329
+ }
330
+ logger.warn(`Docker service "${dockerService}" not running \u2014 falling back to local execution`);
331
+ return new LocalExecutor({ dryRun });
332
+ }
333
+
334
+ // src/gates/validator.ts
335
+ import { z as z2 } from "zod";
336
+ var EcosystemScanResultSchema = z2.object({
337
+ vulnerabilities_total: z2.number(),
338
+ auto_safe: z2.number(),
339
+ breaking: z2.number(),
340
+ manual: z2.number(),
341
+ auto_safe_packages: z2.array(z2.string()),
342
+ breaking_packages: z2.array(z2.string()),
343
+ manual_packages: z2.array(z2.string())
344
+ });
345
+ var ScanResultSchema = z2.object({
346
+ $schema: z2.literal("osv-scan-result/v1"),
347
+ agent: z2.literal("osv-scanner"),
348
+ status: z2.enum(["success", "error", "skipped"]),
349
+ environment: z2.enum(["docker", "local"]),
350
+ php: EcosystemScanResultSchema,
351
+ npm: EcosystemScanResultSchema,
352
+ error: z2.string().nullable()
353
+ });
354
+ var UpdateResultSchema = z2.object({
355
+ $schema: z2.literal("osv-update-result/v1"),
356
+ agent: z2.enum(["composer-safe-update", "npm-safe-update"]),
357
+ status: z2.enum(["success", "error", "skipped"]),
358
+ packages_updated: z2.array(z2.string()),
359
+ packages_skipped: z2.array(z2.string()),
360
+ packages_pending_breaking: z2.array(z2.string()),
361
+ tests: z2.enum(["pass", "fail", "skipped"]),
362
+ tests_detail: z2.string(),
363
+ build_status: z2.enum(["pass", "fail", "skipped"]).optional(),
364
+ build_detail: z2.string().optional(),
365
+ error: z2.string().nullable()
366
+ });
367
+ function validateGateA(data) {
368
+ const result = ScanResultSchema.safeParse(data);
369
+ if (!result.success) {
370
+ return {
371
+ valid: false,
372
+ gate: "A",
373
+ errors: result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`)
374
+ };
375
+ }
376
+ if (result.data.status === "error") {
377
+ return {
378
+ valid: false,
379
+ gate: "A",
380
+ errors: [`Scanner returned error: ${result.data.error ?? "unknown"}`]
381
+ };
382
+ }
383
+ return { valid: true, gate: "A", errors: [] };
384
+ }
385
+ function validateGateB(data) {
386
+ const result = UpdateResultSchema.safeParse(data);
387
+ if (!result.success) {
388
+ return {
389
+ valid: false,
390
+ gate: "B",
391
+ errors: result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`)
392
+ };
393
+ }
394
+ if (result.data.agent !== "npm-safe-update") {
395
+ return {
396
+ valid: false,
397
+ gate: "B",
398
+ errors: [`Expected agent "npm-safe-update", got "${result.data.agent}"`]
399
+ };
400
+ }
401
+ if (result.data.status === "error") {
402
+ return {
403
+ valid: false,
404
+ gate: "B",
405
+ errors: [`npm updater returned error: ${result.data.error ?? "unknown"}`]
406
+ };
407
+ }
408
+ return { valid: true, gate: "B", errors: [] };
409
+ }
410
+ function validateGateC(data) {
411
+ const result = UpdateResultSchema.safeParse(data);
412
+ if (!result.success) {
413
+ return {
414
+ valid: false,
415
+ gate: "C",
416
+ errors: result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`)
417
+ };
418
+ }
419
+ if (result.data.agent !== "composer-safe-update") {
420
+ return {
421
+ valid: false,
422
+ gate: "C",
423
+ errors: [`Expected agent "composer-safe-update", got "${result.data.agent}"`]
424
+ };
425
+ }
426
+ if (result.data.status === "error") {
427
+ return {
428
+ valid: false,
429
+ gate: "C",
430
+ errors: [`Composer updater returned error: ${result.data.error ?? "unknown"}`]
431
+ };
432
+ }
433
+ return { valid: true, gate: "C", errors: [] };
434
+ }
435
+
436
+ // src/utils/osv-commands.ts
437
+ var OSV = {
438
+ scanAll: "osv-scanner --lockfile composer.lock --lockfile package-lock.json --format json",
439
+ scanPhp: "osv-scanner --lockfile composer.lock --format json",
440
+ scanNpm: "osv-scanner --lockfile package-lock.json --format json",
441
+ fixNpm: "osv-scanner fix --strategy=in-place -L package-lock.json",
442
+ checkAvailable: "osv-scanner --version"
443
+ };
444
+
445
+ // src/phases/scanner.ts
446
+ function emptyEcosystem() {
447
+ return {
448
+ vulnerabilities_total: 0,
449
+ auto_safe: 0,
450
+ breaking: 0,
451
+ manual: 0,
452
+ auto_safe_packages: [],
453
+ breaking_packages: [],
454
+ manual_packages: []
455
+ };
456
+ }
457
+ function extractSafeVersion(vulnerabilities) {
458
+ for (const vuln of vulnerabilities) {
459
+ for (const affected of vuln.affected ?? []) {
460
+ for (const range of affected.ranges ?? []) {
461
+ for (const event of range.events ?? []) {
462
+ if (event.fixed) return event.fixed;
463
+ }
464
+ }
465
+ }
466
+ }
467
+ return null;
468
+ }
469
+ function classifyPackageIntoEcosystem(pkgName, pkgVersion, ecosystem, safeVersion, protectedComposer, protectedNpm, php, npm) {
470
+ const isPhp = ecosystem === "packagist" || ecosystem === "composer";
471
+ const isNpm = ecosystem === "npm";
472
+ const target = isPhp ? php : isNpm ? npm : null;
473
+ if (!target) return;
474
+ const isProtected = (isPhp ? protectedComposer : protectedNpm).has(pkgName);
475
+ const packageRef = `${pkgName}@${pkgVersion}`;
476
+ target.vulnerabilities_total++;
477
+ if (!safeVersion || isProtected) {
478
+ target.breaking++;
479
+ target.breaking_packages.push(packageRef);
480
+ } else {
481
+ target.auto_safe++;
482
+ target.auto_safe_packages.push(packageRef);
483
+ }
484
+ }
485
+ function parseOsvJsonOutput(stdout, config) {
486
+ const data = JSON.parse(stdout);
487
+ const php = emptyEcosystem();
488
+ const npm = emptyEcosystem();
489
+ if (!data.results) return { php, npm };
490
+ const protectedComposer = new Set(config.protected_packages.composer.map((p) => p.package));
491
+ const protectedNpm = new Set(config.protected_packages.npm.map((p) => p.package));
492
+ for (const result of data.results) {
493
+ for (const pkg of result.packages ?? []) {
494
+ const pkgName = pkg.package?.name ?? "";
495
+ const pkgVersion = pkg.package?.version ?? "";
496
+ const ecosystem = pkg.package?.ecosystem?.toLowerCase() ?? "";
497
+ const safeVersion = extractSafeVersion(pkg.vulnerabilities ?? []);
498
+ classifyPackageIntoEcosystem(
499
+ pkgName,
500
+ pkgVersion,
501
+ ecosystem,
502
+ safeVersion,
503
+ protectedComposer,
504
+ protectedNpm,
505
+ php,
506
+ npm
507
+ );
508
+ }
509
+ }
510
+ return { php, npm };
511
+ }
512
+ async function assertOsvScannerAvailable(runner, cwd) {
513
+ const result = await runner.run(OSV.checkAvailable, { cwd });
514
+ if (result.exitCode !== 0) {
515
+ throw new EnvironmentError(
516
+ "osv-scanner not found. Install it with: brew install osv-scanner (macOS) or see https://github.com/google/osv-scanner"
517
+ );
518
+ }
519
+ }
520
+ async function executeScan(runner, cwd) {
521
+ logger.debug(`Running: ${OSV.scanAll}`);
522
+ return runner.run(OSV.scanAll, { cwd });
523
+ }
524
+ async function runScanner(runner, config, cwd) {
525
+ logger.info("Phase 1: Running OSV vulnerability scan...");
526
+ const base = {
527
+ $schema: "osv-scan-result/v1",
528
+ agent: "osv-scanner",
529
+ status: "success",
530
+ environment: runner.environment,
531
+ php: emptyEcosystem(),
532
+ npm: emptyEcosystem(),
533
+ error: null
534
+ };
535
+ try {
536
+ await assertOsvScannerAvailable(runner, cwd);
537
+ if (runner.dryRun) {
538
+ logger.info(`[DRY-RUN] Would execute: ${OSV.scanAll}`);
539
+ return base;
540
+ }
541
+ const scanResult = await executeScan(runner, cwd);
542
+ if (scanResult.exitCode !== 0 && !scanResult.stdout) {
543
+ return {
544
+ ...base,
545
+ status: "error",
546
+ error: `Scan failed (exit ${scanResult.exitCode}): ${scanResult.stderr}`
547
+ };
548
+ }
549
+ const parsed = parseOsvJsonOutput(scanResult.stdout, config);
550
+ return { ...base, ...parsed };
551
+ } catch (err) {
552
+ if (err instanceof EnvironmentError) throw err;
553
+ throw new PhaseError(
554
+ `OSV scanner phase failed: ${err instanceof Error ? err.message : String(err)}`,
555
+ "scanner",
556
+ err
557
+ );
558
+ }
559
+ }
560
+
561
+ // src/utils/git.ts
562
+ async function revertFiles(runner, files, cwd) {
563
+ if (files.length === 0) return;
564
+ const fileList = files.join(" ");
565
+ await runner.run(`git checkout -- ${fileList}`, { cwd });
566
+ }
567
+ async function isWorkingTreeClean(runner, files, cwd) {
568
+ const result = await runner.run(`git status --porcelain -- ${files.join(" ")}`, { cwd });
569
+ return result.exitCode === 0 && result.stdout.trim() === "";
570
+ }
571
+
572
+ // src/phases/npm-updater.ts
573
+ var NPM_FILES = ["package.json", "package-lock.json"];
574
+ async function checkCurrentState(runner, cwd) {
575
+ logger.debug("Running npm outdated and npm audit (informational)...");
576
+ await runner.run("npm outdated", { cwd });
577
+ await runner.run("npm audit", { cwd });
578
+ }
579
+ async function applyOsvFix(runner, cwd) {
580
+ logger.info(`Applying OSV in-place fix: ${OSV.fixNpm}`);
581
+ const result = await runner.run(OSV.fixNpm, { cwd });
582
+ if (result.exitCode !== 0) {
583
+ logger.warn(`osv-scanner fix exited with ${result.exitCode}: ${result.stderr}`);
584
+ }
585
+ }
586
+ async function runNpmUpdate(runner, cwd) {
587
+ logger.info("Running npm update...");
588
+ return runner.run("npm update", { cwd });
589
+ }
590
+ async function validateBuilds(runner, config, cwd) {
591
+ logger.info("Validating frontend build...");
592
+ const frontend = await runner.run(config.runtime.build_commands.frontend, { cwd });
593
+ logger.info("Validating backend build...");
594
+ const backend = await runner.run(config.runtime.build_commands.backend, { cwd });
595
+ return { frontend, backend };
596
+ }
597
+ async function revertNpmChanges(runner, cwd) {
598
+ await revertFiles(runner, NPM_FILES, cwd);
599
+ await runner.run("npm install", { cwd });
600
+ }
601
+ async function verifyResidualVulnerabilities(runner, cwd) {
602
+ logger.info(`Running post-update OSV verification: ${OSV.scanNpm}`);
603
+ await runner.run(OSV.scanNpm, { cwd });
604
+ }
605
+ async function runNpmUpdater(runner, config, scanResult, cwd) {
606
+ logger.info("Phase 2: Running npm safe updates...");
607
+ const base = {
608
+ $schema: "osv-update-result/v1",
609
+ agent: "npm-safe-update",
610
+ status: "success",
611
+ packages_updated: [],
612
+ packages_skipped: [],
613
+ packages_pending_breaking: scanResult.npm.breaking_packages,
614
+ tests: "skipped",
615
+ tests_detail: "Build validated; unit tests not applicable to npm phase",
616
+ build_status: "skipped",
617
+ build_detail: "",
618
+ error: null
619
+ };
620
+ if (runner.dryRun) {
621
+ logger.info(`[DRY-RUN] Would execute: ${OSV.fixNpm}`);
622
+ logger.info("[DRY-RUN] Would execute: npm update");
623
+ logger.info(`[DRY-RUN] Would execute: ${config.runtime.build_commands.frontend}`);
624
+ logger.info(`[DRY-RUN] Would execute: ${config.runtime.build_commands.backend}`);
625
+ logger.info(`[DRY-RUN] Would execute: ${OSV.scanNpm}`);
626
+ return { ...base, build_status: "pass", build_detail: "Dry-run \u2014 not executed" };
627
+ }
628
+ try {
629
+ const isClean = await isWorkingTreeClean(runner, NPM_FILES, cwd);
630
+ if (!isClean) {
631
+ return {
632
+ ...base,
633
+ status: "error",
634
+ error: "package.json or package-lock.json has uncommitted changes \u2014 aborting to prevent data loss on revert"
635
+ };
636
+ }
637
+ await checkCurrentState(runner, cwd);
638
+ await applyOsvFix(runner, cwd);
639
+ const updateResult = await runNpmUpdate(runner, cwd);
640
+ if (updateResult.exitCode !== 0) {
641
+ return {
642
+ ...base,
643
+ status: "error",
644
+ build_status: "fail",
645
+ error: `npm update failed: ${updateResult.stderr}`
646
+ };
647
+ }
648
+ const { frontend, backend } = await validateBuilds(runner, config, cwd);
649
+ if (frontend.exitCode !== 0) {
650
+ logger.error("Frontend build failed \u2014 reverting...");
651
+ await revertNpmChanges(runner, cwd);
652
+ return {
653
+ ...base,
654
+ status: "error",
655
+ build_status: "fail",
656
+ build_detail: `Frontend build failed: ${frontend.stderr}`,
657
+ error: "Frontend build failed after npm update \u2014 changes reverted"
658
+ };
659
+ }
660
+ if (backend.exitCode !== 0) {
661
+ logger.error("Backend build failed \u2014 reverting...");
662
+ await revertNpmChanges(runner, cwd);
663
+ return {
664
+ ...base,
665
+ status: "error",
666
+ build_status: "fail",
667
+ build_detail: `Backend build failed: ${backend.stderr}`,
668
+ error: "Backend build failed after npm update \u2014 changes reverted"
669
+ };
670
+ }
671
+ await verifyResidualVulnerabilities(runner, cwd);
672
+ return {
673
+ ...base,
674
+ packages_updated: scanResult.npm.auto_safe_packages,
675
+ build_status: "pass",
676
+ build_detail: "Frontend and backend builds passed after update"
677
+ };
678
+ } catch (err) {
679
+ throw new PhaseError(
680
+ `npm updater phase failed: ${err instanceof Error ? err.message : String(err)}`,
681
+ "npm-updater",
682
+ err
683
+ );
684
+ }
685
+ }
686
+
687
+ // src/phases/composer-updater.ts
688
+ var COMPOSER_FILES = ["composer.json", "composer.lock"];
689
+ function extractPackageNames(packageRefs) {
690
+ return packageRefs.map((ref) => {
691
+ const atIndex = ref.lastIndexOf("@");
692
+ return atIndex > 0 ? ref.slice(0, atIndex) : ref;
693
+ });
694
+ }
695
+ async function checkCurrentState2(runner, cwd) {
696
+ logger.debug("Running composer outdated --direct (informational)...");
697
+ await runner.run("composer outdated --direct", { cwd });
698
+ }
699
+ async function applyComposerUpdate(runner, packageNames, cwd) {
700
+ const pkgList = packageNames.join(" ");
701
+ logger.info(`Updating packages: ${pkgList}`);
702
+ return runner.run(`composer update ${pkgList} --no-interaction`, { cwd });
703
+ }
704
+ async function runTestSuite(runner, testCommand, cwd) {
705
+ logger.info(`Running tests: ${testCommand}`);
706
+ return runner.run(testCommand, { cwd });
707
+ }
708
+ async function revertComposerChanges(runner, cwd) {
709
+ await revertFiles(runner, COMPOSER_FILES, cwd);
710
+ await runner.run("composer install --no-interaction", { cwd });
711
+ }
712
+ async function verifyResidualVulnerabilities2(runner, cwd) {
713
+ logger.info(`Running post-update OSV verification: ${OSV.scanPhp}`);
714
+ await runner.run(OSV.scanPhp, { cwd });
715
+ }
716
+ async function runComposerUpdater(runner, config, scanResult, cwd) {
717
+ logger.info("Phase 3: Running Composer safe updates...");
718
+ const base = {
719
+ $schema: "osv-update-result/v1",
720
+ agent: "composer-safe-update",
721
+ status: "success",
722
+ packages_updated: [],
723
+ packages_skipped: [],
724
+ packages_pending_breaking: scanResult.php.breaking_packages,
725
+ tests: "skipped",
726
+ tests_detail: "",
727
+ error: null
728
+ };
729
+ const autoSafePackageNames = extractPackageNames(scanResult.php.auto_safe_packages);
730
+ if (autoSafePackageNames.length === 0) {
731
+ return { ...base, tests_detail: "No auto-safe packages to update" };
732
+ }
733
+ if (runner.dryRun) {
734
+ logger.info(`[DRY-RUN] Would execute: composer update ${autoSafePackageNames.join(" ")} --no-interaction`);
735
+ logger.info(`[DRY-RUN] Would execute: ${config.runtime.test_command}`);
736
+ logger.info(`[DRY-RUN] Would execute: ${OSV.scanPhp}`);
737
+ return {
738
+ ...base,
739
+ packages_updated: scanResult.php.auto_safe_packages,
740
+ tests_detail: "Dry-run \u2014 not executed"
741
+ };
742
+ }
743
+ try {
744
+ const isClean = await isWorkingTreeClean(runner, COMPOSER_FILES, cwd);
745
+ if (!isClean) {
746
+ return {
747
+ ...base,
748
+ status: "error",
749
+ error: "composer.json or composer.lock has uncommitted changes \u2014 aborting to prevent data loss on revert"
750
+ };
751
+ }
752
+ await checkCurrentState2(runner, cwd);
753
+ const updateResult = await applyComposerUpdate(runner, autoSafePackageNames, cwd);
754
+ if (updateResult.exitCode !== 0) {
755
+ return {
756
+ ...base,
757
+ status: "error",
758
+ tests: "skipped",
759
+ error: `composer update failed: ${updateResult.stderr}`
760
+ };
761
+ }
762
+ const testResult = await runTestSuite(runner, config.runtime.test_command, cwd);
763
+ if (testResult.exitCode !== 0) {
764
+ logger.error("Tests failed \u2014 reverting Composer updates...");
765
+ await revertComposerChanges(runner, cwd);
766
+ return {
767
+ ...base,
768
+ status: "error",
769
+ tests: "fail",
770
+ tests_detail: testResult.stdout || testResult.stderr,
771
+ error: "Tests failed after composer update \u2014 changes reverted"
772
+ };
773
+ }
774
+ await verifyResidualVulnerabilities2(runner, cwd);
775
+ const testDetail = testResult.stdout.trim().split("\n").slice(-2).join(" ");
776
+ return {
777
+ ...base,
778
+ packages_updated: scanResult.php.auto_safe_packages,
779
+ tests: "pass",
780
+ tests_detail: testDetail || "Tests passed"
781
+ };
782
+ } catch (err) {
783
+ throw new PhaseError(
784
+ `Composer updater phase failed: ${err instanceof Error ? err.message : String(err)}`,
785
+ "composer-updater",
786
+ err
787
+ );
788
+ }
789
+ }
790
+
791
+ // src/phases/orchestrator.ts
792
+ function shouldRunPhase(phase, options) {
793
+ if (!options.phases) return true;
794
+ return options.phases.includes(phase);
795
+ }
796
+ async function runOrchestrator(runner, config, options) {
797
+ const result = {
798
+ scan: null,
799
+ npmUpdate: null,
800
+ composerUpdate: null,
801
+ overallStatus: "success"
802
+ };
803
+ if (!shouldRunPhase("scan", options)) {
804
+ logger.warn('Skipping scan phase \u2014 phases option does not include "scan"');
805
+ result.overallStatus = "skipped";
806
+ return result;
807
+ }
808
+ logger.info("=== Phase 1: Vulnerability Scan ===");
809
+ const scanResult = await runScanner(runner, config, options.cwd);
810
+ result.scan = scanResult;
811
+ const gateA = validateGateA(scanResult);
812
+ if (!gateA.valid) {
813
+ throw new GateValidationError(
814
+ `Gate A validation failed: ${gateA.errors.join(", ")}`,
815
+ "A",
816
+ gateA.errors
817
+ );
818
+ }
819
+ logger.info(
820
+ `Scan complete: ${scanResult.php.vulnerabilities_total} PHP vulns (${scanResult.php.auto_safe} auto-safe, ${scanResult.php.breaking} breaking), ${scanResult.npm.vulnerabilities_total} npm vulns (${scanResult.npm.auto_safe} auto-safe, ${scanResult.npm.breaking} breaking)`
821
+ );
822
+ const hasNpmUpdates = scanResult.npm.auto_safe > 0;
823
+ const hasPhpUpdates = scanResult.php.auto_safe > 0;
824
+ if (!hasNpmUpdates && !hasPhpUpdates) {
825
+ logger.info("No auto-safe vulnerabilities found \u2014 no updates needed");
826
+ return result;
827
+ }
828
+ if (shouldRunPhase("npm", options) && hasNpmUpdates) {
829
+ logger.info("=== Phase 2: npm Safe Updates ===");
830
+ const npmResult = await runNpmUpdater(runner, config, scanResult, options.cwd);
831
+ result.npmUpdate = npmResult;
832
+ const gateB = validateGateB(npmResult);
833
+ if (!gateB.valid) {
834
+ throw new GateValidationError(
835
+ `Gate B validation failed: ${gateB.errors.join(", ")}`,
836
+ "B",
837
+ gateB.errors
838
+ );
839
+ }
840
+ if (npmResult.build_status === "fail") {
841
+ logger.error("npm build failed \u2014 stopping before Composer phase");
842
+ result.overallStatus = "error";
843
+ return result;
844
+ }
845
+ logger.info(
846
+ `npm update complete: ${npmResult.packages_updated.length} packages updated`
847
+ );
848
+ } else if (!hasNpmUpdates) {
849
+ logger.info("Phase 2: Skipping npm update \u2014 no auto-safe npm vulnerabilities");
850
+ }
851
+ if (shouldRunPhase("composer", options) && hasPhpUpdates) {
852
+ logger.info("=== Phase 3: Composer Safe Updates ===");
853
+ const composerResult = await runComposerUpdater(runner, config, scanResult, options.cwd);
854
+ result.composerUpdate = composerResult;
855
+ const gateC = validateGateC(composerResult);
856
+ if (!gateC.valid) {
857
+ throw new GateValidationError(
858
+ `Gate C validation failed: ${gateC.errors.join(", ")}`,
859
+ "C",
860
+ gateC.errors
861
+ );
862
+ }
863
+ if (composerResult.tests === "fail") {
864
+ logger.error("Composer tests failed \u2014 workflow stopped");
865
+ result.overallStatus = "error";
866
+ return result;
867
+ }
868
+ logger.info(
869
+ `Composer update complete: ${composerResult.packages_updated.length} packages updated`
870
+ );
871
+ } else if (!hasPhpUpdates) {
872
+ logger.info("Phase 3: Skipping Composer update \u2014 no auto-safe PHP vulnerabilities");
873
+ }
874
+ const hasPendingItems = scanResult.php.breaking > 0 || scanResult.npm.breaking > 0 || scanResult.php.manual > 0 || scanResult.npm.manual > 0;
875
+ if (hasPendingItems) {
876
+ result.overallStatus = "error";
877
+ }
878
+ return result;
879
+ }
880
+
881
+ // src/report/consolidated.ts
882
+ function generateConsolidatedReport(data) {
883
+ const lines = [];
884
+ lines.push(`# Security Report \u2014 ${data.projectName}`);
885
+ lines.push(`**Date:** ${data.date}`);
886
+ lines.push(`**Environment:** ${data.environment}`);
887
+ lines.push("");
888
+ lines.push("## Vulnerabilities Found");
889
+ const scan = data.scan;
890
+ const totalVulns = scan.php.vulnerabilities_total + scan.npm.vulnerabilities_total;
891
+ lines.push(`- **Total:** ${totalVulns}`);
892
+ lines.push(
893
+ `- **PHP (auto-safe/breaking/manual):** ${scan.php.auto_safe}/${scan.php.breaking}/${scan.php.manual}`
894
+ );
895
+ lines.push(
896
+ `- **npm (auto-safe/breaking/manual):** ${scan.npm.auto_safe}/${scan.npm.breaking}/${scan.npm.manual}`
897
+ );
898
+ lines.push("");
899
+ lines.push("## Fixes Applied");
900
+ if (data.npmUpdate && data.npmUpdate.packages_updated.length > 0) {
901
+ lines.push("### npm");
902
+ for (const pkg of data.npmUpdate.packages_updated) {
903
+ lines.push(`- ${pkg}`);
904
+ }
905
+ } else {
906
+ lines.push("### npm");
907
+ lines.push("- No packages updated");
908
+ }
909
+ lines.push("");
910
+ if (data.composerUpdate && data.composerUpdate.packages_updated.length > 0) {
911
+ lines.push("### Composer (PHP)");
912
+ for (const pkg of data.composerUpdate.packages_updated) {
913
+ lines.push(`- ${pkg}`);
914
+ }
915
+ } else {
916
+ lines.push("### Composer (PHP)");
917
+ lines.push("- No packages updated");
918
+ }
919
+ lines.push("");
920
+ lines.push("## Validation After Updates");
921
+ if (data.composerUpdate) {
922
+ const testStatus = data.composerUpdate.tests === "pass" ? "\u2705 PASS" : data.composerUpdate.tests === "fail" ? "\u274C FAIL" : "\u2014 skipped";
923
+ lines.push(`- PHP test suite: ${testStatus}`);
924
+ if (data.composerUpdate.tests_detail) {
925
+ lines.push(` ${data.composerUpdate.tests_detail}`);
926
+ }
927
+ }
928
+ if (data.npmUpdate) {
929
+ const buildStatus = data.npmUpdate.build_status === "pass" ? "\u2705 PASS" : data.npmUpdate.build_status === "fail" ? "\u274C FAIL" : "\u2014 skipped";
930
+ lines.push(`- npm build: ${buildStatus}`);
931
+ if (data.npmUpdate.build_detail) {
932
+ lines.push(` ${data.npmUpdate.build_detail}`);
933
+ }
934
+ }
935
+ lines.push("");
936
+ const pendingBreaking = [
937
+ ...scan.php.breaking_packages.map((p) => `[PHP] ${p}`),
938
+ ...scan.npm.breaking_packages.map((p) => `[npm] ${p}`)
939
+ ];
940
+ const pendingManual = [
941
+ ...scan.php.manual_packages.map((p) => `[PHP] ${p}`),
942
+ ...scan.npm.manual_packages.map((p) => `[npm] ${p}`)
943
+ ];
944
+ if (pendingBreaking.length > 0 || pendingManual.length > 0) {
945
+ lines.push("## Pending \u2014 Require Manual Action");
946
+ if (pendingBreaking.length > 0) {
947
+ lines.push("### Require BREAKING CHANGE (awaiting per-package authorization)");
948
+ for (const pkg of pendingBreaking) {
949
+ lines.push(`- ${pkg}`);
950
+ lines.push(
951
+ ` To authorize: "sim, confirmo breaking changes para [package]"`
952
+ );
953
+ }
954
+ lines.push("");
955
+ }
956
+ if (pendingManual.length > 0) {
957
+ lines.push("### No safe version within current constraint");
958
+ for (const pkg of pendingManual) {
959
+ lines.push(`- ${pkg}`);
960
+ }
961
+ lines.push("");
962
+ }
963
+ }
964
+ return lines.join("\n");
965
+ }
966
+
967
+ // src/report/executive.ts
968
+ function monthName(date) {
969
+ return date.toLocaleString("en-US", { month: "long" });
970
+ }
971
+ function monthNamePt(date) {
972
+ const months = [
973
+ "Janeiro",
974
+ "Fevereiro",
975
+ "Mar\xE7o",
976
+ "Abril",
977
+ "Maio",
978
+ "Junho",
979
+ "Julho",
980
+ "Agosto",
981
+ "Setembro",
982
+ "Outubro",
983
+ "Novembro",
984
+ "Dezembro"
985
+ ];
986
+ return months[date.getMonth()];
987
+ }
988
+ function zeroPad(n) {
989
+ return String(n).padStart(2, "0");
990
+ }
991
+ function generateExecutiveReport(opts) {
992
+ const now = /* @__PURE__ */ new Date();
993
+ const year = now.getFullYear();
994
+ const month = zeroPad(now.getMonth() + 1);
995
+ const monthFull = monthNamePt(now);
996
+ const npmFixed = opts.npmUpdate?.packages_updated ?? [];
997
+ const composerFixed = opts.composerUpdate?.packages_updated ?? [];
998
+ const allFixed = [...composerFixed.map((p) => ({ type: "Composer", pkg: p })), ...npmFixed.map((p) => ({ type: "npm", pkg: p }))];
999
+ const npmBreaking = opts.scanBefore.npm.breaking_packages;
1000
+ const phpBreaking = opts.scanBefore.php.breaking_packages;
1001
+ const allPending = [
1002
+ ...phpBreaking.map((p) => ({ type: "Composer", pkg: p })),
1003
+ ...npmBreaking.map((p) => ({ type: "npm", pkg: p }))
1004
+ ];
1005
+ const hasFixed = allFixed.length > 0;
1006
+ const hasPending = allPending.length > 0;
1007
+ const noneFound = opts.scanBefore.php.vulnerabilities_total === 0 && opts.scanBefore.npm.vulnerabilities_total === 0;
1008
+ const lines = [];
1009
+ lines.push(`Cliente: ${opts.client}`);
1010
+ lines.push(`Projeto: ${opts.project}`);
1011
+ lines.push(`Per\xEDodo: ${monthFull} ${year}`);
1012
+ lines.push("");
1013
+ lines.push("---");
1014
+ lines.push("");
1015
+ lines.push("Tarefa");
1016
+ lines.push("");
1017
+ lines.push("Manuten\xE7\xE3o de Seguran\xE7a \u2014 OSV Scanner (rotina mensal)");
1018
+ lines.push("");
1019
+ lines.push(
1020
+ "Verifica\xE7\xE3o mensal das depend\xEAncias instaladas (PHP/Composer e npm) para identificar pacotes com vulnerabilidades conhecidas e aplicar corre\xE7\xF5es dispon\xEDveis."
1021
+ );
1022
+ lines.push("");
1023
+ lines.push("---");
1024
+ lines.push("");
1025
+ lines.push("Resolu\xE7\xE3o");
1026
+ lines.push("");
1027
+ if (noneFound) {
1028
+ lines.push(
1029
+ "Nenhuma vulnerabilidade foi identificada nas depend\xEAncias PHP ou npm. O projeto est\xE1 atualizado e seguro."
1030
+ );
1031
+ } else if (hasFixed) {
1032
+ lines.push(
1033
+ "Ap\xF3s a execu\xE7\xE3o da varredura, os seguintes problemas foram encontrados e corrigidos:"
1034
+ );
1035
+ lines.push("");
1036
+ lines.push(
1037
+ "| Tipo | Pacote | Vers\xE3o Corrigida | Risco |"
1038
+ );
1039
+ lines.push("|------|--------|-----------------|-------|");
1040
+ for (const { type, pkg } of allFixed) {
1041
+ lines.push(`| ${type} | ${pkg} | \u2014 | \u2014 |`);
1042
+ }
1043
+ }
1044
+ if (hasPending) {
1045
+ lines.push("");
1046
+ lines.push(
1047
+ "As seguintes vulnerabilidades n\xE3o puderam ser corrigidas automaticamente e permanecem pendentes:"
1048
+ );
1049
+ lines.push("");
1050
+ lines.push("| Tipo | Pacote | Vers\xE3o Atual | Motivo |");
1051
+ lines.push("|------|--------|-------------|--------|");
1052
+ for (const { type, pkg } of allPending) {
1053
+ lines.push(`| ${type} | ${pkg} | \u2014 | Requer mudan\xE7a disruptiva |`);
1054
+ }
1055
+ }
1056
+ lines.push("");
1057
+ lines.push("---");
1058
+ lines.push("");
1059
+ lines.push("Resumo");
1060
+ lines.push("");
1061
+ if (noneFound) {
1062
+ lines.push(
1063
+ "Nenhuma vulnerabilidade foi identificada nas depend\xEAncias PHP ou npm. O projeto est\xE1 atualizado e seguro."
1064
+ );
1065
+ } else if (hasFixed && !hasPending) {
1066
+ lines.push(
1067
+ "Todas as vulnerabilidades identificadas foram corrigidas. O projeto est\xE1 atualizado e seguro em rela\xE7\xE3o \xE0s suas depend\xEAncias."
1068
+ );
1069
+ } else if (hasFixed && hasPending) {
1070
+ lines.push(
1071
+ "Todas as vulnerabilidades que puderam ser corrigidas sem mudan\xE7as disruptivas foram aplicadas. Os itens listados acima requerem avalia\xE7\xE3o ou autoriza\xE7\xE3o de vers\xE3o principal."
1072
+ );
1073
+ } else {
1074
+ lines.push(
1075
+ "Vulnerabilidades identificadas requerem a\xE7\xE3o manual \u2014 nenhuma corre\xE7\xE3o autom\xE1tica foi aplicada."
1076
+ );
1077
+ }
1078
+ return lines.join("\n");
1079
+ }
1080
+ function executiveReportFilename(client, project) {
1081
+ const now = /* @__PURE__ */ new Date();
1082
+ const year = now.getFullYear();
1083
+ const month = String(now.getMonth() + 1).padStart(2, "0");
1084
+ const monthEn = monthName(now);
1085
+ return `[${client} ${project}] Report OSV Scanner - ${year}-${month} - ${monthEn}.md`;
1086
+ }
1087
+
1088
+ // bin/osv-security.ts
1089
+ var program = new Command();
1090
+ program.name("osv-security").description("OSV vulnerability scanning and safe dependency update CLI").version("0.1.0");
1091
+ var commonOptions = (cmd) => cmd.option("-c, --config <path>", "Path to project-config.yml", DEFAULT_CONFIG_PATH).option("--cwd <path>", "Working directory", process.cwd()).option("--dry-run", "Show commands without executing", false).option("-v, --verbose", "Verbose output", false).option("-q, --quiet", "Suppress all output except errors and final report", false).option("--json", "Output results as JSON", false).option("-o, --output <path>", "Write report to file");
1092
+ program.command("init").description("Generate a project-config.yml template in the current project").option("--project-name <name>", "Project name").option("--client <name>", "Client name").option("--execution <mode>", "Execution mode: docker or local", "docker").option("--docker-service <service>", "Docker Compose service name", "app").option("--php-version <version>", "PHP version", "8.2").option("--laravel-version <version>", "Laravel version", "10.x").option("--node-version <version>", "Node.js version", "20.x").option("--test-command <cmd>", "Test command", "php artisan test --compact").option("--cwd <path>", "Working directory", process.cwd()).option("--output <path>", "Output path (default: .github/agents/project-config.yml)").option("--force", "Overwrite existing file", false).action(async (opts) => {
1093
+ const { access, mkdir } = await import("fs/promises");
1094
+ const { dirname } = await import("path");
1095
+ const outputPath = opts.output ? resolve2(opts.cwd, opts.output) : resolve2(opts.cwd, DEFAULT_CONFIG_PATH);
1096
+ if (!opts.force) {
1097
+ try {
1098
+ await access(outputPath);
1099
+ process.stderr.write(
1100
+ `File already exists: ${outputPath}
1101
+ Use --force to overwrite.
1102
+ `
1103
+ );
1104
+ process.exit(3);
1105
+ } catch {
1106
+ }
1107
+ }
1108
+ const yaml = generateConfigYaml({
1109
+ projectName: opts.projectName,
1110
+ client: opts.client,
1111
+ execution: opts.execution,
1112
+ dockerService: opts.dockerService,
1113
+ phpVersion: opts.phpVersion,
1114
+ laravelVersion: opts.laravelVersion,
1115
+ nodeVersion: opts.nodeVersion,
1116
+ testCommand: opts.testCommand
1117
+ });
1118
+ await mkdir(dirname(outputPath), { recursive: true });
1119
+ await writeFile(outputPath, yaml, "utf-8");
1120
+ process.stdout.write(`Created: ${outputPath}
1121
+ `);
1122
+ process.stdout.write(`
1123
+ Next steps:
1124
+ `);
1125
+ process.stdout.write(` 1. Edit ${outputPath} to match your project
1126
+ `);
1127
+ process.stdout.write(` 2. Review protected_packages \u2014 add any packages that must not be auto-upgraded
1128
+ `);
1129
+ process.stdout.write(` 3. Run: osv-security scan --cwd <your-project-dir>
1130
+ `);
1131
+ });
1132
+ commonOptions(
1133
+ program.command("scan").description("Run vulnerability scan only (Phase 1)")
1134
+ ).action(async (opts) => {
1135
+ await runCommand("scan", opts);
1136
+ });
1137
+ commonOptions(
1138
+ program.command("fix").description("Run full workflow: scan + npm fix + composer fix").option("--phases <phases>", "Comma-separated phases: scan,npm,composer,report", "scan,npm,composer")
1139
+ ).action(async (opts) => {
1140
+ await runCommand("fix", opts);
1141
+ });
1142
+ commonOptions(
1143
+ program.command("executive-report").description("Generate executive report (Phase 5)").requiredOption("--client <name>", "Client name").requiredOption("--project <name>", "Project name")
1144
+ ).action(async (opts) => {
1145
+ await runCommand("executive-report", opts);
1146
+ });
1147
+ async function runCommand(command, opts) {
1148
+ if (opts.verbose) setLogLevel("debug");
1149
+ if (opts.quiet) setLogLevel("error");
1150
+ let exitCode = 0;
1151
+ try {
1152
+ const config = await loadConfig(opts.config, opts.cwd);
1153
+ const runner = await detectEnvironment(
1154
+ config.runtime.execution,
1155
+ config.runtime.docker_service,
1156
+ opts.cwd,
1157
+ opts.dryRun
1158
+ );
1159
+ if (command === "scan") {
1160
+ const scanResult = await runScanner(runner, config, opts.cwd);
1161
+ const output = opts.json ? JSON.stringify(scanResult, null, 2) : formatScanSummary(scanResult);
1162
+ await writeOutput(output, opts.output);
1163
+ if (scanResult.status === "error") exitCode = 2;
1164
+ else if (scanResult.php.breaking > 0 || scanResult.npm.breaking > 0) exitCode = 1;
1165
+ } else if (command === "fix") {
1166
+ const phases = opts.phases ? opts.phases.split(",") : void 0;
1167
+ const result = await runOrchestrator(runner, config, {
1168
+ configPath: opts.config,
1169
+ cwd: opts.cwd,
1170
+ dryRun: opts.dryRun,
1171
+ verbose: opts.verbose,
1172
+ phases
1173
+ });
1174
+ if (result.scan) {
1175
+ const report = {
1176
+ projectName: config.project.name,
1177
+ date: (/* @__PURE__ */ new Date()).toISOString().split("T")[0],
1178
+ environment: runner.environment,
1179
+ scan: result.scan,
1180
+ npmUpdate: result.npmUpdate,
1181
+ composerUpdate: result.composerUpdate,
1182
+ overallStatus: result.overallStatus
1183
+ };
1184
+ const output = opts.json ? JSON.stringify(result, null, 2) : generateConsolidatedReport(report);
1185
+ await writeOutput(output, opts.output);
1186
+ }
1187
+ if (result.overallStatus === "error") exitCode = 1;
1188
+ } else if (command === "executive-report") {
1189
+ const scanBefore = await runScanner(runner, config, opts.cwd);
1190
+ const orchestratorResult = await runOrchestrator(runner, config, {
1191
+ configPath: opts.config,
1192
+ cwd: opts.cwd,
1193
+ dryRun: opts.dryRun,
1194
+ verbose: opts.verbose
1195
+ });
1196
+ const scanAfter = await runScanner(runner, config, opts.cwd);
1197
+ const report = generateExecutiveReport({
1198
+ client: opts.client,
1199
+ project: opts.project,
1200
+ scanBefore,
1201
+ scanAfter,
1202
+ npmUpdate: orchestratorResult.npmUpdate,
1203
+ composerUpdate: orchestratorResult.composerUpdate
1204
+ });
1205
+ const filename = executiveReportFilename(opts.client, opts.project);
1206
+ const reportDir = resolve2(opts.cwd, ".osv-scanner/reports");
1207
+ const reportPath = opts.output ?? resolve2(reportDir, filename);
1208
+ await writeOutput(report, reportPath);
1209
+ process.stdout.write(`Report saved to: ${reportPath}
1210
+ `);
1211
+ }
1212
+ } catch (err) {
1213
+ if (err instanceof ConfigLoadError) {
1214
+ process.stderr.write(`Configuration error: ${err.message}
1215
+ `);
1216
+ exitCode = 3;
1217
+ } else if (err instanceof GateValidationError) {
1218
+ process.stderr.write(`Gate ${err.gate} validation failed:
1219
+ `);
1220
+ for (const e of err.errors) process.stderr.write(` - ${e}
1221
+ `);
1222
+ exitCode = 2;
1223
+ } else if (err instanceof PhaseError) {
1224
+ process.stderr.write(`Phase "${err.phase}" failed: ${err.message}
1225
+ `);
1226
+ exitCode = 2;
1227
+ } else {
1228
+ process.stderr.write(`Unexpected error: ${err instanceof Error ? err.message : String(err)}
1229
+ `);
1230
+ exitCode = 2;
1231
+ }
1232
+ }
1233
+ process.exit(exitCode);
1234
+ }
1235
+ function formatScanSummary(scan) {
1236
+ const lines = [
1237
+ `## OSV Scan Report \u2014 ${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]}`,
1238
+ `**Environment:** ${scan.environment}`,
1239
+ "",
1240
+ "### PHP (composer.lock)",
1241
+ `- Total: ${scan.php.vulnerabilities_total}`,
1242
+ `- Auto-safe: ${scan.php.auto_safe}`,
1243
+ `- Breaking: ${scan.php.breaking}`,
1244
+ `- Manual: ${scan.php.manual}`,
1245
+ "",
1246
+ "### npm (package-lock.json)",
1247
+ `- Total: ${scan.npm.vulnerabilities_total}`,
1248
+ `- Auto-safe: ${scan.npm.auto_safe}`,
1249
+ `- Breaking: ${scan.npm.breaking}`,
1250
+ `- Manual: ${scan.npm.manual}`
1251
+ ];
1252
+ if (scan.error) {
1253
+ lines.push("", `**Warning:** ${scan.error}`);
1254
+ }
1255
+ return lines.join("\n");
1256
+ }
1257
+ async function writeOutput(content, outputPath) {
1258
+ if (outputPath) {
1259
+ const { mkdir } = await import("fs/promises");
1260
+ const { dirname } = await import("path");
1261
+ await mkdir(dirname(outputPath), { recursive: true });
1262
+ await writeFile(outputPath, content, "utf-8");
1263
+ } else {
1264
+ process.stdout.write(content + "\n");
1265
+ }
1266
+ }
1267
+ program.parse();
1268
+ //# sourceMappingURL=osv-security.js.map