diff --git a/coverage.out b/coverage.out new file mode 100644 index 0000000..9d1b6f0 --- /dev/null +++ b/coverage.out @@ -0,0 +1,570 @@ +mode: set +github.com/MikeRoss27/scanforge/internal/cli/auth.go:13.50,28.55 2 0 +github.com/MikeRoss27/scanforge/internal/cli/auth.go:28.55,33.18 4 0 +github.com/MikeRoss27/scanforge/internal/cli/auth.go:33.18,35.5 1 0 +github.com/MikeRoss27/scanforge/internal/cli/auth.go:37.4,38.37 2 0 +github.com/MikeRoss27/scanforge/internal/cli/auth.go:38.37,40.5 1 0 +github.com/MikeRoss27/scanforge/internal/cli/auth.go:42.4,44.14 3 0 +github.com/MikeRoss27/scanforge/internal/cli/auth.go:48.2,52.55 1 0 +github.com/MikeRoss27/scanforge/internal/cli/auth.go:52.55,54.18 2 0 +github.com/MikeRoss27/scanforge/internal/cli/auth.go:54.18,56.5 1 0 +github.com/MikeRoss27/scanforge/internal/cli/auth.go:58.4,58.31 1 0 +github.com/MikeRoss27/scanforge/internal/cli/auth.go:58.31,61.5 2 0 +github.com/MikeRoss27/scanforge/internal/cli/auth.go:63.4,64.46 2 0 +github.com/MikeRoss27/scanforge/internal/cli/auth.go:64.46,65.39 1 0 +github.com/MikeRoss27/scanforge/internal/cli/auth.go:65.39,67.22 2 0 +github.com/MikeRoss27/scanforge/internal/cli/auth.go:67.22,69.7 1 0 +github.com/MikeRoss27/scanforge/internal/cli/auth.go:70.6,70.70 1 0 +github.com/MikeRoss27/scanforge/internal/cli/auth.go:73.4,73.14 1 0 +github.com/MikeRoss27/scanforge/internal/cli/auth.go:77.2,81.55 1 0 +github.com/MikeRoss27/scanforge/internal/cli/auth.go:81.55,83.18 2 0 +github.com/MikeRoss27/scanforge/internal/cli/auth.go:83.18,85.5 1 0 +github.com/MikeRoss27/scanforge/internal/cli/auth.go:86.4,86.21 1 0 +github.com/MikeRoss27/scanforge/internal/cli/auth.go:90.2,94.12 4 0 +github.com/MikeRoss27/scanforge/internal/cli/diff.go:12.58,24.55 2 0 +github.com/MikeRoss27/scanforge/internal/cli/diff.go:24.55,29.18 2 0 +github.com/MikeRoss27/scanforge/internal/cli/diff.go:29.18,31.5 1 0 +github.com/MikeRoss27/scanforge/internal/cli/diff.go:32.4,33.15 2 0 +github.com/MikeRoss27/scanforge/internal/cli/diff.go:33.15,35.19 2 0 +github.com/MikeRoss27/scanforge/internal/cli/diff.go:35.19,37.6 1 0 +github.com/MikeRoss27/scanforge/internal/cli/diff.go:38.5,39.15 2 0 +github.com/MikeRoss27/scanforge/internal/cli/diff.go:41.4,42.14 2 0 +github.com/MikeRoss27/scanforge/internal/cli/diff.go:45.2,46.12 2 0 +github.com/MikeRoss27/scanforge/internal/cli/doctor.go:8.60,23.55 4 0 +github.com/MikeRoss27/scanforge/internal/cli/doctor.go:23.55,29.4 1 0 +github.com/MikeRoss27/scanforge/internal/cli/doctor.go:32.2,36.12 4 0 +github.com/MikeRoss27/scanforge/internal/cli/export.go:11.60,25.55 3 0 +github.com/MikeRoss27/scanforge/internal/cli/export.go:25.55,27.18 2 0 +github.com/MikeRoss27/scanforge/internal/cli/export.go:27.18,29.5 1 0 +github.com/MikeRoss27/scanforge/internal/cli/export.go:30.4,35.18 2 0 +github.com/MikeRoss27/scanforge/internal/cli/export.go:35.18,37.5 1 0 +github.com/MikeRoss27/scanforge/internal/cli/export.go:38.4,40.14 2 0 +github.com/MikeRoss27/scanforge/internal/cli/export.go:43.2,45.12 3 0 +github.com/MikeRoss27/scanforge/internal/cli/init.go:8.58,21.55 2 0 +github.com/MikeRoss27/scanforge/internal/cli/init.go:21.55,25.4 1 0 +github.com/MikeRoss27/scanforge/internal/cli/init.go:28.2,30.12 2 0 +github.com/MikeRoss27/scanforge/internal/cli/plan.go:13.58,32.55 8 1 +github.com/MikeRoss27/scanforge/internal/cli/plan.go:32.55,35.21 1 0 +github.com/MikeRoss27/scanforge/internal/cli/plan.go:35.21,37.5 1 0 +github.com/MikeRoss27/scanforge/internal/cli/plan.go:38.4,39.21 2 0 +github.com/MikeRoss27/scanforge/internal/cli/plan.go:39.21,41.5 1 0 +github.com/MikeRoss27/scanforge/internal/cli/plan.go:42.4,46.18 2 0 +github.com/MikeRoss27/scanforge/internal/cli/plan.go:46.18,48.5 1 0 +github.com/MikeRoss27/scanforge/internal/cli/plan.go:49.4,50.31 2 0 +github.com/MikeRoss27/scanforge/internal/cli/plan.go:50.31,52.5 1 0 +github.com/MikeRoss27/scanforge/internal/cli/plan.go:53.4,53.14 1 0 +github.com/MikeRoss27/scanforge/internal/cli/plan.go:56.2,63.12 8 1 +github.com/MikeRoss27/scanforge/internal/cli/plan.go:66.58,67.39 1 0 +github.com/MikeRoss27/scanforge/internal/cli/plan.go:67.39,69.3 1 0 +github.com/MikeRoss27/scanforge/internal/cli/plan.go:71.2,76.42 6 0 +github.com/MikeRoss27/scanforge/internal/cli/plan.go:76.42,78.3 1 0 +github.com/MikeRoss27/scanforge/internal/cli/plan.go:79.2,79.26 1 0 +github.com/MikeRoss27/scanforge/internal/cli/plan.go:79.26,81.3 1 0 +github.com/MikeRoss27/scanforge/internal/cli/plan.go:83.2,84.34 2 0 +github.com/MikeRoss27/scanforge/internal/cli/plan.go:84.34,86.29 2 0 +github.com/MikeRoss27/scanforge/internal/cli/plan.go:86.29,88.4 1 0 +github.com/MikeRoss27/scanforge/internal/cli/plan.go:89.3,94.5 1 0 +github.com/MikeRoss27/scanforge/internal/cli/plan.go:96.2,99.26 3 0 +github.com/MikeRoss27/scanforge/internal/cli/root.go:17.38,42.61 3 0 +github.com/MikeRoss27/scanforge/internal/cli/root.go:42.61,44.4 1 0 +github.com/MikeRoss27/scanforge/internal/cli/root.go:49.2,49.63 1 0 +github.com/MikeRoss27/scanforge/internal/cli/root.go:49.63,52.3 2 0 +github.com/MikeRoss27/scanforge/internal/cli/root.go:55.2,74.12 16 0 +github.com/MikeRoss27/scanforge/internal/cli/run.go:11.57,64.55 26 1 +github.com/MikeRoss27/scanforge/internal/cli/run.go:64.55,67.21 1 0 +github.com/MikeRoss27/scanforge/internal/cli/run.go:67.21,69.5 1 0 +github.com/MikeRoss27/scanforge/internal/cli/run.go:70.4,71.21 2 0 +github.com/MikeRoss27/scanforge/internal/cli/run.go:71.21,73.5 1 0 +github.com/MikeRoss27/scanforge/internal/cli/run.go:74.4,104.6 1 0 +github.com/MikeRoss27/scanforge/internal/cli/run.go:108.2,138.12 26 1 +github.com/MikeRoss27/scanforge/internal/cli/update.go:8.60,19.55 2 0 +github.com/MikeRoss27/scanforge/internal/cli/update.go:19.55,21.4 1 0 +github.com/MikeRoss27/scanforge/internal/cli/update.go:24.2,26.12 2 0 +github.com/MikeRoss27/scanforge/internal/cli/version.go:12.41,17.48 1 0 +github.com/MikeRoss27/scanforge/internal/cli/version.go:17.48,24.4 6 0 +github.com/MikeRoss27/scanforge/internal/cli/version.go:27.2,27.12 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:59.34,65.2 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:110.39,110.85 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:112.52,114.2 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:132.63,134.16 2 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:134.16,136.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:137.2,138.16 2 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:138.16,140.3 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:141.2,142.33 2 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:142.33,145.44 3 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:145.44,147.4 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:149.2,149.29 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:152.66,154.16 2 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:154.16,156.3 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:158.2,165.18 6 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:165.18,166.79 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:166.79,168.4 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:174.2,174.41 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:180.64,182.16 2 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:182.16,184.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:186.2,186.23 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:186.23,188.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:190.2,191.19 2 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:191.19,193.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:195.2,203.16 2 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:203.16,205.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:207.2,207.55 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:207.55,209.3 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:210.2,210.54 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:210.54,211.79 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:211.79,213.4 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:216.2,219.16 3 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:219.16,221.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:222.2,224.70 3 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:224.70,226.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:227.2,231.48 5 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:231.48,233.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:235.2,241.8 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:247.78,249.19 2 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:249.19,251.3 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:251.8,253.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:255.2,277.12 12 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:277.12,292.3 2 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:294.2,294.65 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:294.65,298.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:299.2,299.24 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:306.128,307.60 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:307.60,309.56 2 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:309.56,314.4 4 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:317.3,317.44 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:317.44,319.4 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:322.3,325.13 4 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:327.2,327.31 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:327.31,329.3 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:330.2,331.12 2 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:336.55,337.12 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:337.12,338.23 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:338.24,339.4 0 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:343.59,344.27 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:345.35,346.68 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:347.37,348.21 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:348.21,350.4 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:351.36,352.39 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:352.39,354.4 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:355.34,356.27 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:357.33,358.27 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:359.33,360.18 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:368.48,370.20 2 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:370.20,372.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:373.2,374.20 2 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:374.20,376.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:377.2,377.26 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:384.71,387.9 3 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:388.16,390.17 2 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:391.29,393.20 2 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:394.29,396.20 2 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:398.2,401.15 3 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:401.15,403.3 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:404.2,404.9 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:405.58,406.42 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:407.16,408.42 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:409.29,410.64 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:411.29,412.46 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:413.23,414.49 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:417.2,418.20 2 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:418.20,420.3 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:421.2,421.32 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:426.86,429.33 3 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:429.33,430.35 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:430.35,432.4 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:435.2,435.51 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:435.51,437.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:437.8,438.10 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:439.58,440.43 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:441.30,442.40 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:443.11,444.41 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:448.2,448.33 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:448.33,453.46 2 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:453.46,455.4 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:458.2,458.34 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:464.55,469.16 4 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:469.16,473.3 3 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:475.2,477.88 3 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:477.88,480.3 2 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:481.2,481.12 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:486.105,487.37 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:487.37,489.3 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:491.2,492.17 2 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:492.17,494.3 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:496.2,504.15 8 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:511.109,513.86 2 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:513.86,514.91 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:514.91,516.4 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:519.2,521.30 3 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:521.30,522.21 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:523.20,524.20 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:525.29,528.89 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:529.11,530.87 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:534.2,534.37 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:534.37,536.3 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:538.2,539.33 2 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:540.19,541.26 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:542.17,543.27 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:544.16,545.24 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:548.2,552.47 5 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:552.47,554.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:555.2,556.28 2 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:556.28,558.3 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:559.2,559.29 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:559.29,561.3 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:562.2,564.89 2 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:573.44,579.29 5 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:579.29,580.20 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:580.20,582.12 2 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:584.3,584.54 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:584.54,587.12 3 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:589.3,589.25 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:591.2,591.19 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:591.19,593.3 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:594.2,594.41 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:599.45,600.59 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:600.59,603.3 2 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:609.48,610.16 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:610.16,612.3 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:614.2,616.35 3 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:616.35,620.40 4 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:620.40,622.4 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:625.2,626.16 2 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:626.16,628.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:629.2,629.15 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:629.15,631.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:632.2,632.15 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:632.15,634.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:636.2,637.25 2 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:637.25,639.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:640.2,641.20 2 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:641.20,643.28 2 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:643.28,645.4 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:646.3,646.65 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:649.2,649.36 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:652.39,653.16 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:654.19,655.44 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:656.17,657.43 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:658.16,659.39 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:660.10,661.45 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:665.57,667.16 2 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:667.16,669.3 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:670.2,670.35 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:670.35,671.46 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:671.46,673.4 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:675.2,675.15 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:678.57,683.29 4 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:683.29,684.30 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:684.30,687.4 2 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:689.2,689.16 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:689.16,691.3 1 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:692.2,692.34 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:695.58,718.2 22 1 +github.com/MikeRoss27/scanforge/internal/app/app.go:720.69,722.16 2 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:722.16,724.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:726.2,733.16 3 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:733.16,735.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:737.2,737.15 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:737.15,739.17 2 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:739.17,741.4 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:742.3,742.22 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:743.8,747.3 3 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:749.2,749.19 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:749.19,751.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:753.2,753.12 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:756.65,758.16 2 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:758.16,759.39 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:759.39,761.4 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:762.3,762.39 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:762.39,764.4 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:765.3,765.13 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:768.2,768.38 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:768.38,770.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:771.2,771.38 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:771.38,773.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/app.go:775.2,784.12 8 0 +github.com/MikeRoss27/scanforge/internal/app/diff.go:22.93,24.16 2 0 +github.com/MikeRoss27/scanforge/internal/app/diff.go:24.16,26.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/diff.go:27.2,28.16 2 0 +github.com/MikeRoss27/scanforge/internal/app/diff.go:28.16,30.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/diff.go:31.2,31.35 1 0 +github.com/MikeRoss27/scanforge/internal/app/diff.go:31.35,33.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/diff.go:35.2,36.16 2 0 +github.com/MikeRoss27/scanforge/internal/app/diff.go:36.16,38.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/diff.go:39.2,40.16 2 0 +github.com/MikeRoss27/scanforge/internal/app/diff.go:40.16,42.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/diff.go:44.2,45.50 2 0 +github.com/MikeRoss27/scanforge/internal/app/export.go:33.79,35.16 2 0 +github.com/MikeRoss27/scanforge/internal/app/export.go:35.16,37.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/export.go:38.2,39.16 2 0 +github.com/MikeRoss27/scanforge/internal/app/export.go:39.16,41.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/export.go:43.2,44.15 2 0 +github.com/MikeRoss27/scanforge/internal/app/export.go:44.15,45.22 1 0 +github.com/MikeRoss27/scanforge/internal/app/export.go:46.20,47.52 1 0 +github.com/MikeRoss27/scanforge/internal/app/export.go:48.25,49.62 1 0 +github.com/MikeRoss27/scanforge/internal/app/export.go:50.11,51.116 1 0 +github.com/MikeRoss27/scanforge/internal/app/export.go:55.2,55.21 1 0 +github.com/MikeRoss27/scanforge/internal/app/export.go:56.19,57.28 1 0 +github.com/MikeRoss27/scanforge/internal/app/export.go:58.24,59.33 1 0 +github.com/MikeRoss27/scanforge/internal/app/export.go:60.10,61.115 1 0 +github.com/MikeRoss27/scanforge/internal/app/export.go:63.2,63.16 1 0 +github.com/MikeRoss27/scanforge/internal/app/export.go:63.16,65.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/export.go:66.2,66.17 1 0 +github.com/MikeRoss27/scanforge/internal/app/export.go:70.60,71.51 1 0 +github.com/MikeRoss27/scanforge/internal/app/export.go:72.27,73.26 1 0 +github.com/MikeRoss27/scanforge/internal/app/export.go:74.32,75.31 1 0 +github.com/MikeRoss27/scanforge/internal/app/export.go:76.10,77.98 1 0 +github.com/MikeRoss27/scanforge/internal/app/notify.go:34.109,35.27 1 1 +github.com/MikeRoss27/scanforge/internal/app/notify.go:35.27,37.3 1 1 +github.com/MikeRoss27/scanforge/internal/app/notify.go:39.2,41.16 3 1 +github.com/MikeRoss27/scanforge/internal/app/notify.go:41.16,43.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/notify.go:45.2,48.16 4 1 +github.com/MikeRoss27/scanforge/internal/app/notify.go:48.16,50.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/notify.go:51.2,55.16 4 1 +github.com/MikeRoss27/scanforge/internal/app/notify.go:55.16,57.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/notify.go:58.2,58.15 1 1 +github.com/MikeRoss27/scanforge/internal/app/notify.go:58.15,58.40 1 1 +github.com/MikeRoss27/scanforge/internal/app/notify.go:59.2,59.28 1 1 +github.com/MikeRoss27/scanforge/internal/app/notify.go:59.28,61.3 1 1 +github.com/MikeRoss27/scanforge/internal/app/notify.go:62.2,62.12 1 1 +github.com/MikeRoss27/scanforge/internal/app/notify.go:65.83,68.35 3 1 +github.com/MikeRoss27/scanforge/internal/app/notify.go:68.35,70.46 2 1 +github.com/MikeRoss27/scanforge/internal/app/notify.go:70.46,72.4 1 1 +github.com/MikeRoss27/scanforge/internal/app/notify.go:74.2,75.35 2 1 +github.com/MikeRoss27/scanforge/internal/app/notify.go:75.35,77.3 1 1 +github.com/MikeRoss27/scanforge/internal/app/notify.go:79.2,80.30 2 1 +github.com/MikeRoss27/scanforge/internal/app/notify.go:80.30,82.3 1 1 +github.com/MikeRoss27/scanforge/internal/app/notify.go:83.2,88.20 3 1 +github.com/MikeRoss27/scanforge/internal/app/notify.go:88.20,90.29 2 1 +github.com/MikeRoss27/scanforge/internal/app/notify.go:90.29,92.4 1 1 +github.com/MikeRoss27/scanforge/internal/app/notify.go:93.3,93.34 1 1 +github.com/MikeRoss27/scanforge/internal/app/notify.go:96.2,106.3 1 1 +github.com/MikeRoss27/scanforge/internal/app/plan.go:41.61,43.16 2 1 +github.com/MikeRoss27/scanforge/internal/app/plan.go:43.16,45.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/plan.go:46.2,47.16 2 1 +github.com/MikeRoss27/scanforge/internal/app/plan.go:47.16,49.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/plan.go:50.2,51.23 2 1 +github.com/MikeRoss27/scanforge/internal/app/plan.go:51.23,53.3 1 1 +github.com/MikeRoss27/scanforge/internal/app/plan.go:55.2,56.33 2 1 +github.com/MikeRoss27/scanforge/internal/app/plan.go:56.33,58.17 2 1 +github.com/MikeRoss27/scanforge/internal/app/plan.go:58.17,60.4 1 0 +github.com/MikeRoss27/scanforge/internal/app/plan.go:61.3,61.36 1 1 +github.com/MikeRoss27/scanforge/internal/app/plan.go:63.2,63.21 1 1 +github.com/MikeRoss27/scanforge/internal/app/plan.go:66.110,75.16 2 1 +github.com/MikeRoss27/scanforge/internal/app/plan.go:75.16,77.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/plan.go:79.2,80.16 2 1 +github.com/MikeRoss27/scanforge/internal/app/plan.go:80.16,82.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/plan.go:83.2,84.16 2 1 +github.com/MikeRoss27/scanforge/internal/app/plan.go:84.16,86.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/plan.go:87.2,88.16 2 1 +github.com/MikeRoss27/scanforge/internal/app/plan.go:88.16,90.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/plan.go:91.2,100.32 2 1 +github.com/MikeRoss27/scanforge/internal/app/plan.go:100.32,106.3 1 1 +github.com/MikeRoss27/scanforge/internal/app/plan.go:107.2,107.20 1 1 +github.com/MikeRoss27/scanforge/internal/app/plan.go:110.37,111.14 1 1 +github.com/MikeRoss27/scanforge/internal/app/plan.go:112.26,113.19 1 1 +github.com/MikeRoss27/scanforge/internal/app/plan.go:114.78,115.22 1 1 +github.com/MikeRoss27/scanforge/internal/app/plan.go:116.25,117.18 1 0 +github.com/MikeRoss27/scanforge/internal/app/plan.go:118.32,119.23 1 0 +github.com/MikeRoss27/scanforge/internal/app/plan.go:120.10,121.19 1 1 +github.com/MikeRoss27/scanforge/internal/app/scope.go:43.47,45.2 1 1 +github.com/MikeRoss27/scanforge/internal/app/scope.go:47.46,49.2 1 0 +github.com/MikeRoss27/scanforge/internal/app/scope.go:51.79,52.32 1 0 +github.com/MikeRoss27/scanforge/internal/app/scope.go:52.32,54.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/scope.go:55.2,56.25 2 0 +github.com/MikeRoss27/scanforge/internal/app/scope.go:56.25,58.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/scope.go:59.2,59.41 1 0 +github.com/MikeRoss27/scanforge/internal/app/scope.go:59.41,61.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/scope.go:62.2,65.36 3 0 +github.com/MikeRoss27/scanforge/internal/app/scope.go:65.36,67.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/scope.go:68.2,69.46 2 0 +github.com/MikeRoss27/scanforge/internal/app/scope.go:77.136,81.24 3 1 +github.com/MikeRoss27/scanforge/internal/app/scope.go:81.24,82.21 1 1 +github.com/MikeRoss27/scanforge/internal/app/scope.go:82.21,84.4 1 1 +github.com/MikeRoss27/scanforge/internal/app/scope.go:85.3,85.72 1 1 +github.com/MikeRoss27/scanforge/internal/app/scope.go:88.2,88.47 1 1 +github.com/MikeRoss27/scanforge/internal/app/scope.go:88.47,90.10 2 1 +github.com/MikeRoss27/scanforge/internal/app/scope.go:91.35,92.87 1 0 +github.com/MikeRoss27/scanforge/internal/app/scope.go:93.19,95.22 2 1 +github.com/MikeRoss27/scanforge/internal/app/scope.go:95.22,97.5 1 0 +github.com/MikeRoss27/scanforge/internal/app/scope.go:98.4,98.23 1 1 +github.com/MikeRoss27/scanforge/internal/app/scope.go:98.23,100.5 1 1 +github.com/MikeRoss27/scanforge/internal/app/scope.go:101.4,101.102 1 1 +github.com/MikeRoss27/scanforge/internal/app/scope.go:102.28,103.99 1 0 +github.com/MikeRoss27/scanforge/internal/app/scope.go:104.11,105.90 1 1 +github.com/MikeRoss27/scanforge/internal/app/scope.go:107.8,107.27 1 1 +github.com/MikeRoss27/scanforge/internal/app/scope.go:107.27,109.3 1 1 +github.com/MikeRoss27/scanforge/internal/app/scope.go:111.2,112.16 2 1 +github.com/MikeRoss27/scanforge/internal/app/scope.go:112.16,114.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/scope.go:115.2,116.16 2 1 +github.com/MikeRoss27/scanforge/internal/app/scope.go:116.16,118.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/scope.go:119.2,119.30 1 1 +github.com/MikeRoss27/scanforge/internal/app/scope.go:119.30,121.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/scope.go:122.2,131.8 1 1 +github.com/MikeRoss27/scanforge/internal/app/scope.go:134.93,136.16 2 1 +github.com/MikeRoss27/scanforge/internal/app/scope.go:136.16,138.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/scope.go:139.2,139.30 1 1 +github.com/MikeRoss27/scanforge/internal/app/scope.go:139.30,140.19 1 1 +github.com/MikeRoss27/scanforge/internal/app/scope.go:140.19,142.4 1 1 +github.com/MikeRoss27/scanforge/internal/app/scope.go:143.3,143.18 1 1 +github.com/MikeRoss27/scanforge/internal/app/scope.go:145.2,153.8 1 1 +github.com/MikeRoss27/scanforge/internal/app/scope.go:156.59,157.67 1 1 +github.com/MikeRoss27/scanforge/internal/app/scope.go:158.31,159.34 1 1 +github.com/MikeRoss27/scanforge/internal/app/scope.go:160.28,161.35 1 1 +github.com/MikeRoss27/scanforge/internal/app/scope.go:162.10,163.82 1 0 +github.com/MikeRoss27/scanforge/internal/app/scope.go:167.74,168.15 1 1 +github.com/MikeRoss27/scanforge/internal/app/scope.go:168.15,170.3 1 1 +github.com/MikeRoss27/scanforge/internal/app/scope.go:171.2,171.56 1 1 +github.com/MikeRoss27/scanforge/internal/app/scope.go:171.56,173.3 1 1 +github.com/MikeRoss27/scanforge/internal/app/scope.go:174.2,175.16 2 1 +github.com/MikeRoss27/scanforge/internal/app/scope.go:175.16,177.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/scope.go:178.2,178.15 1 1 +github.com/MikeRoss27/scanforge/internal/app/scope.go:178.15,180.3 1 1 +github.com/MikeRoss27/scanforge/internal/app/scope.go:181.2,181.12 1 0 +github.com/MikeRoss27/scanforge/internal/app/targets.go:11.49,13.16 2 1 +github.com/MikeRoss27/scanforge/internal/app/targets.go:13.16,15.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/targets.go:16.2,18.57 3 1 +github.com/MikeRoss27/scanforge/internal/app/targets.go:18.57,20.49 2 1 +github.com/MikeRoss27/scanforge/internal/app/targets.go:20.49,21.12 1 1 +github.com/MikeRoss27/scanforge/internal/app/targets.go:23.3,23.30 1 1 +github.com/MikeRoss27/scanforge/internal/app/targets.go:23.30,24.12 1 1 +github.com/MikeRoss27/scanforge/internal/app/targets.go:26.3,27.34 2 1 +github.com/MikeRoss27/scanforge/internal/app/targets.go:29.2,29.23 1 1 +github.com/MikeRoss27/scanforge/internal/app/targets.go:29.23,31.3 1 1 +github.com/MikeRoss27/scanforge/internal/app/targets.go:32.2,32.21 1 1 +github.com/MikeRoss27/scanforge/internal/app/targets.go:37.66,38.39 1 1 +github.com/MikeRoss27/scanforge/internal/app/targets.go:38.39,40.3 1 1 +github.com/MikeRoss27/scanforge/internal/app/targets.go:41.2,41.39 1 1 +github.com/MikeRoss27/scanforge/internal/app/targets.go:41.39,43.3 1 1 +github.com/MikeRoss27/scanforge/internal/app/targets.go:44.2,44.23 1 1 +github.com/MikeRoss27/scanforge/internal/app/targets.go:44.23,46.17 2 1 +github.com/MikeRoss27/scanforge/internal/app/targets.go:46.17,48.4 1 0 +github.com/MikeRoss27/scanforge/internal/app/targets.go:49.3,49.22 1 1 +github.com/MikeRoss27/scanforge/internal/app/targets.go:51.2,51.30 1 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:30.69,31.47 1 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:31.47,33.3 1 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:35.2,36.16 2 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:36.16,38.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/update.go:40.2,40.91 1 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:40.91,43.3 2 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:45.2,48.16 3 0 +github.com/MikeRoss27/scanforge/internal/app/update.go:48.16,50.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/update.go:51.2,52.16 2 0 +github.com/MikeRoss27/scanforge/internal/app/update.go:52.16,54.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/update.go:55.2,55.55 1 0 +github.com/MikeRoss27/scanforge/internal/app/update.go:55.55,57.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/update.go:59.2,59.45 1 0 +github.com/MikeRoss27/scanforge/internal/app/update.go:59.45,62.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/update.go:63.2,65.33 2 0 +github.com/MikeRoss27/scanforge/internal/app/update.go:70.61,72.16 2 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:72.16,74.3 1 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:75.2,76.51 2 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:76.51,78.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/update.go:79.2,79.24 1 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:79.24,81.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/update.go:82.2,82.18 1 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:87.67,88.61 1 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:88.61,90.3 1 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:91.2,91.38 1 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:91.38,93.3 1 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:94.2,95.16 2 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:95.16,97.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/update.go:98.2,98.49 1 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:101.26,102.31 1 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:102.31,104.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/update.go:105.2,105.20 1 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:108.37,110.16 2 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:110.16,112.3 1 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:113.2,116.13 4 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:119.52,121.16 2 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:121.16,123.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/update.go:124.2,124.54 1 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:124.54,126.3 1 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:127.2,128.16 2 0 +github.com/MikeRoss27/scanforge/internal/app/update.go:128.16,130.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/update.go:131.2,131.60 1 0 +github.com/MikeRoss27/scanforge/internal/app/update.go:131.60,133.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/update.go:134.2,134.58 1 0 +github.com/MikeRoss27/scanforge/internal/app/update.go:141.75,143.16 2 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:143.16,146.3 2 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:147.2,157.34 6 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:157.34,159.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/update.go:161.2,162.16 2 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:162.16,164.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/update.go:165.2,166.46 2 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:166.46,168.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/update.go:169.2,169.12 1 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:174.69,177.16 2 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:177.16,179.3 1 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:180.2,180.35 1 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:180.35,181.19 1 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:181.19,183.4 1 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:184.3,184.13 1 0 +github.com/MikeRoss27/scanforge/internal/app/update.go:186.2,187.56 2 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:187.56,189.23 2 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:189.23,190.12 1 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:192.3,192.42 1 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:192.42,194.4 1 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:194.9,196.4 1 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:198.2,198.9 1 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:199.20,200.28 1 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:201.19,202.27 1 0 +github.com/MikeRoss27/scanforge/internal/app/update.go:203.10,204.81 1 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:210.39,211.45 1 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:211.45,213.3 1 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:214.2,215.16 2 0 +github.com/MikeRoss27/scanforge/internal/app/update.go:215.16,217.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/update.go:218.2,218.56 1 0 +github.com/MikeRoss27/scanforge/internal/app/update.go:218.56,220.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/update.go:221.2,221.23 1 0 +github.com/MikeRoss27/scanforge/internal/app/update.go:224.74,225.17 1 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:225.17,227.3 1 1 +github.com/MikeRoss27/scanforge/internal/app/update.go:228.2,239.29 4 0 +github.com/MikeRoss27/scanforge/internal/app/update.go:239.29,243.36 3 0 +github.com/MikeRoss27/scanforge/internal/app/update.go:243.36,245.4 1 0 +github.com/MikeRoss27/scanforge/internal/app/update.go:245.9,247.4 1 0 +github.com/MikeRoss27/scanforge/internal/app/update.go:249.2,250.12 2 0 +github.com/MikeRoss27/scanforge/internal/app/wizard.go:32.49,34.2 1 1 +github.com/MikeRoss27/scanforge/internal/app/wizard.go:36.47,38.2 1 1 +github.com/MikeRoss27/scanforge/internal/app/wizard.go:41.42,42.15 1 1 +github.com/MikeRoss27/scanforge/internal/app/wizard.go:42.15,44.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/wizard.go:45.2,49.31 4 1 +github.com/MikeRoss27/scanforge/internal/app/wizard.go:52.62,53.32 1 1 +github.com/MikeRoss27/scanforge/internal/app/wizard.go:53.32,55.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/wizard.go:56.2,58.6 3 1 +github.com/MikeRoss27/scanforge/internal/app/wizard.go:58.6,61.17 3 1 +github.com/MikeRoss27/scanforge/internal/app/wizard.go:61.17,63.4 1 0 +github.com/MikeRoss27/scanforge/internal/app/wizard.go:64.3,64.55 1 1 +github.com/MikeRoss27/scanforge/internal/app/wizard.go:64.55,66.4 1 1 +github.com/MikeRoss27/scanforge/internal/app/wizard.go:67.3,67.72 1 1 +github.com/MikeRoss27/scanforge/internal/app/wizard.go:71.97,72.32 1 1 +github.com/MikeRoss27/scanforge/internal/app/wizard.go:72.32,74.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/wizard.go:75.2,76.25 2 1 +github.com/MikeRoss27/scanforge/internal/app/wizard.go:76.25,78.3 1 0 +github.com/MikeRoss27/scanforge/internal/app/wizard.go:79.2,80.6 2 1 +github.com/MikeRoss27/scanforge/internal/app/wizard.go:80.6,83.34 3 1 +github.com/MikeRoss27/scanforge/internal/app/wizard.go:83.34,85.23 2 1 +github.com/MikeRoss27/scanforge/internal/app/wizard.go:85.23,88.5 2 1 +github.com/MikeRoss27/scanforge/internal/app/wizard.go:89.4,89.40 1 1 +github.com/MikeRoss27/scanforge/internal/app/wizard.go:91.3,92.22 2 1 +github.com/MikeRoss27/scanforge/internal/app/wizard.go:92.22,94.4 1 0 +github.com/MikeRoss27/scanforge/internal/app/wizard.go:95.3,98.17 3 1 +github.com/MikeRoss27/scanforge/internal/app/wizard.go:98.17,100.4 1 0 +github.com/MikeRoss27/scanforge/internal/app/wizard.go:101.3,102.36 2 1 +github.com/MikeRoss27/scanforge/internal/app/wizard.go:102.36,104.4 1 1 +github.com/MikeRoss27/scanforge/internal/app/wizard.go:105.3,105.88 1 1 +github.com/MikeRoss27/scanforge/internal/app/wizard.go:105.88,107.4 1 1 +github.com/MikeRoss27/scanforge/internal/app/wizard.go:108.3,108.64 1 0 +github.com/MikeRoss27/scanforge/internal/app/wizard.go:115.64,116.42 1 1 +github.com/MikeRoss27/scanforge/internal/app/wizard.go:116.42,118.3 1 1 +github.com/MikeRoss27/scanforge/internal/app/wizard.go:119.2,119.49 1 1 +github.com/MikeRoss27/scanforge/internal/app/wizard.go:119.49,121.17 2 1 +github.com/MikeRoss27/scanforge/internal/app/wizard.go:121.17,123.4 1 0 +github.com/MikeRoss27/scanforge/internal/app/wizard.go:124.3,124.23 1 1 +github.com/MikeRoss27/scanforge/internal/app/wizard.go:126.2,126.24 1 1 +github.com/MikeRoss27/scanforge/internal/app/wizard.go:126.24,128.45 2 1 +github.com/MikeRoss27/scanforge/internal/app/wizard.go:128.45,130.4 1 1 +github.com/MikeRoss27/scanforge/internal/app/wizard.go:131.3,132.17 2 1 +github.com/MikeRoss27/scanforge/internal/app/wizard.go:132.17,134.4 1 0 +github.com/MikeRoss27/scanforge/internal/app/wizard.go:135.3,135.29 1 1 +github.com/MikeRoss27/scanforge/internal/app/wizard.go:137.2,137.18 1 1 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c0f232c..a9049a0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -49,3 +49,43 @@ report.md readable summary The manifest distinguishes the `completed`, `partial` and `failed` states, references the produced artifacts and keeps the scope source for audit. + +## Triage layer (H3.1) + +`scanforge triage ` derives interpretation from the report without ever +modifying it: + +```text +report.json ──► finding.FromReport ──► canonical findings (deterministic IDs) + │ + ▼ + finding.BuildRelations (L0/L1) + │ + ▼ + triage engine: group → bundle → analyze → validate + │ + ▼ + /triage/ (manifest, relations, insights, report.md) +``` + +The boundary is strict: **ScanForge owns facts, AI owns interpretations, +validation sits between them.** + +- `internal/finding` projects the report into flat findings with + deterministic IDs (`F-` + hash of source|template|asset|matched_at|evidence) + and computes deterministic relations (duplicate 1.00, shared CVE 0.99, + same endpoint 0.95, same asset 0.80). L2 (semantic) relations can add to + them but never override them. +- `internal/triage` runs the pipeline: grouping (union-find over the relation + graph), deterministic insights (summary + duplicate groups), optional LLM + analysis, validation and reconciliation (priority-ordered, stable IDs). +- The LLM receives only a reduced projection (`TriageBundle`): truncated + evidence, no raw tool output, capped at 150 findings. Its output is + validated against the facts — unknown finding IDs, CVEs or evidence strings + reject the whole insight — so the model cannot inject new truths. +- `internal/inference` abstracts the transport behind a `Client` interface; + the bundled implementation speaks the OpenAI-compatible chat completions + API (llama.cpp, vLLM, Ollama, ...). +- Provenance is recorded in `triage/manifest.json` (model, prompt version, + input digest, temperature) and the cache reuses results when the input + digest, model and prompt version are unchanged (`--force` bypasses it). diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 88ee49c..62f92c1 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -64,11 +64,11 @@ continuité (diff entre runs, scheduling, notifications). ### H3 — Vision -| # | Idée | -|---|---| -| H3.1 | Triage IA des findings : résumé LLM + déduplication | -| H3.2 | Report HTML type nuclei (compte rendu client) | -| H3.3 | Données live : fetch EPSS/KEV/NVD à jour plutôt que dataset embarqué | +| # | Idée | Statut | +|---|---|---| +| H3.1 | Triage IA des findings : résumé LLM + déduplication | ✅ implémenté | +| H3.2 | Report HTML type nuclei (compte rendu client) | | +| H3.3 | Données live : fetch EPSS/KEV/NVD à jour plutôt que dataset embarqué | | ## 4. Hors périmètre (anti-scope creep) diff --git a/docs/USAGE.md b/docs/USAGE.md index 7ba7db4..c752471 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -74,6 +74,20 @@ module_timeouts: katana: 20m ``` +The `ai` section configures the LLM backend used by `scanforge triage`. Any +server exposing the OpenAI-compatible chat completions API works (llama.cpp, +vLLM, Ollama, LM Studio, ...). When the section is omitted, triage runs in +deterministic-only mode (deduplication and grouping without a model): + +```yaml +ai: + base_url: http://127.0.0.1:8080/v1 + model: qwen3.5-9b + api_key: "" # optional for local servers + timeout: 5m + temperature: 0.1 # low values keep triage output stable +``` + ## Built-in nuclei templates `--nuclei-include-custom` adds the templates bundled in the `templates/` @@ -124,11 +138,38 @@ scanforge run example.com --scope-mode domain --confirm-scope | `scanforge scan TARGET` | Alias of `run`. | | `scanforge diff RUN1 RUN2` | Delta (assets/ports/vulns) between two runs of the same target. | | `scanforge export RUN --format sarif\|defectdojo` | Exports a run report for CI (SARIF) or DefectDojo (generic findings). | +| `scanforge triage RUN` | Groups, groups and (with an `ai:` backend) analyzes the findings of a run. | | `scanforge auth` | Manages the keys required by some tools. | | `scanforge version` | Displays the binary version. | See `scanforge --help` for the exact list of options. +## Triage of findings + +`scanforge triage ` projects the consolidated report into canonical +findings, computes deterministic relations (duplicates, shared CVE, shared +endpoint, same asset) and writes the result under `/triage/`: + +```text +triage/manifest.json provenance: model, prompt version, input digest +triage/relations.json deterministic finding-to-finding relations +triage/insights.json insights (dedup groups + validated LLM insights) +triage/report.md human-readable summary +``` + +With an `ai:` backend configured, the model receives a deliberately reduced +projection of the findings (truncated evidence, no raw tool output) and its +insights are validated before being stored: any insight referencing an +unknown finding ID, CVE or evidence string is rejected. The model can +interpret findings, never create them. Re-running with unchanged input hits +the cache (0 inference); `--force` bypasses it: + +```bash +scanforge triage runs/example.com/2026-08-19T10:00:00Z +scanforge triage runs/example.com/2026-08-19T10:00:00Z --force +scanforge triage runs/example.com/2026-08-19T10:00:00Z --model qwen3.5-9b +``` + ## Multi-target engagements `run` and `plan` accept a targets file instead of a single positional target. diff --git a/docs/fr/ARCHITECTURE.md b/docs/fr/ARCHITECTURE.md index f6628a1..c58d778 100644 --- a/docs/fr/ARCHITECTURE.md +++ b/docs/fr/ARCHITECTURE.md @@ -49,3 +49,46 @@ report.md synthèse lisible Le manifeste distingue les états `completed`, `partial` et `failed`, référence les artefacts produits et conserve la source du scope pour audit. + +## Couche triage (H3.1) + +`scanforge triage ` dérive une interprétation du rapport sans jamais le +modifier : + +```text +report.json ──► finding.FromReport ──► findings canoniques (IDs déterministes) + │ + ▼ + finding.BuildRelations (L0/L1) + │ + ▼ + moteur triage : group → bundle → analyze → validate + │ + ▼ + /triage/ (manifest, relations, insights, report.md) +``` + +La frontière est stricte : **ScanForge possède les faits, l'IA possède les +interprétations, la validation se tient entre les deux.** + +- `internal/finding` projette le rapport en findings plats avec IDs + déterministes (`F-` + hash de source|template|asset|matched_at|evidence) et + calcule les relations déterministes (doublon 1.00, CVE partagée 0.99, même + endpoint 0.95, même actif 0.80). Les relations sémantiques (L2) peuvent s'y + ajouter mais jamais les surcharger. +- `internal/triage` exécute le pipeline : regroupement (union-find sur le + graphe de relations), insights déterministes (résumé + groupes de doublons), + analyse LLM optionnelle, validation et réconciliation (tri par priorité, IDs + stables). +- Le LLM ne reçoit qu'une projection réduite (`TriageBundle`) : preuves + tronquées, jamais de sortie brute d'outil, plafonnée à 150 findings. Sa + sortie est validée contre les faits — un ID de finding, une CVE ou une + preuve inconnus rejettent l'insight entier — donc le modèle ne peut pas + injecter de nouvelles vérités. +- `internal/inference` abstrait le transport derrière une interface `Client` ; + l'implémentation livrée parle l'API OpenAI-compatible chat completions + (llama.cpp, vLLM, Ollama, ...). +- La provenance est enregistrée dans `triage/manifest.json` (modèle, version + du prompt, empreinte d'entrée, température) et le cache réutilise les + résultats quand l'empreinte d'entrée, le modèle et la version du prompt sont + inchangés (`--force` le contourne). diff --git a/docs/fr/ROADMAP.md b/docs/fr/ROADMAP.md index 88ee49c..62f92c1 100644 --- a/docs/fr/ROADMAP.md +++ b/docs/fr/ROADMAP.md @@ -64,11 +64,11 @@ continuité (diff entre runs, scheduling, notifications). ### H3 — Vision -| # | Idée | -|---|---| -| H3.1 | Triage IA des findings : résumé LLM + déduplication | -| H3.2 | Report HTML type nuclei (compte rendu client) | -| H3.3 | Données live : fetch EPSS/KEV/NVD à jour plutôt que dataset embarqué | +| # | Idée | Statut | +|---|---|---| +| H3.1 | Triage IA des findings : résumé LLM + déduplication | ✅ implémenté | +| H3.2 | Report HTML type nuclei (compte rendu client) | | +| H3.3 | Données live : fetch EPSS/KEV/NVD à jour plutôt que dataset embarqué | | ## 4. Hors périmètre (anti-scope creep) diff --git a/docs/fr/USAGE.md b/docs/fr/USAGE.md index 5aa4f5c..42f03f6 100644 --- a/docs/fr/USAGE.md +++ b/docs/fr/USAGE.md @@ -75,6 +75,21 @@ module_timeouts: katana: 20m ``` +La section `ai` configure le backend LLM utilisé par `scanforge triage`. Tout +serveur exposant l'API OpenAI-compatible chat completions fonctionne +(llama.cpp, vLLM, Ollama, LM Studio, ...). Si la section est absente, le +triage fonctionne en mode purement déterministe (déduplication et +regroupement sans modèle) : + +```yaml +ai: + base_url: http://127.0.0.1:8080/v1 + model: qwen3.5-9b + api_key: "" # facultatif pour les serveurs locaux + timeout: 5m + temperature: 0.1 # des valeurs basses stabilisent le triage +``` + ## Templates nuclei intégrés `--nuclei-include-custom` ajoute au run nuclei les templates livrés dans le @@ -123,11 +138,38 @@ scanforge run example.com --scope-mode domain --confirm-scope | `scanforge plan TARGET` | Affiche le scope et les vagues du DAG. | | `scanforge run TARGET` | Exécute un profil autorisé. | | `scanforge scan TARGET` | Alias de `run`. | +| `scanforge triage RUN` | Regroupe et (avec un backend `ai:`) analyse les findings d'un run. | | `scanforge auth` | Gère les clés requises par certains outils. | | `scanforge version` | Affiche la version du binaire. | Consultez `scanforge --help` pour la liste exacte des options. +## Triage des findings + +`scanforge triage ` projette le rapport consolidé en findings canoniques, +calcule les relations déterministes (doublons, CVE partagée, endpoint commun, +même actif) et écrit le résultat sous `/triage/` : + +```text +triage/manifest.json provenance : modèle, version du prompt, empreinte d'entrée +triage/relations.json relations déterministes entre findings +triage/insights.json insights (groupes de doublons + insights LLM validés) +triage/report.md résumé lisible +``` + +Avec un backend `ai:` configuré, le modèle reçoit une projection volontairement +réduite des findings (preuves tronquées, jamais de sortie brute d'outil) et ses +insights sont validés avant stockage : tout insight référençant un ID de +finding, une CVE ou une preuve inconnus est rejeté. Le modèle peut interpréter +les findings, jamais en créer. Relancer avec une entrée inchangée touche le +cache (0 inférence) ; `--force` le contourne : + +```bash +scanforge triage runs/example.com/2026-08-19T10:00:00Z +scanforge triage runs/example.com/2026-08-19T10:00:00Z --force +scanforge triage runs/example.com/2026-08-19T10:00:00Z --model qwen3.5-9b +``` + ## Engagements multi-cibles `run` et `plan` acceptent un fichier de cibles au lieu d'une cible positionnelle diff --git a/docs/zh/ARCHITECTURE.md b/docs/zh/ARCHITECTURE.md index f9387e4..6b18e53 100644 --- a/docs/zh/ARCHITECTURE.md +++ b/docs/zh/ARCHITECTURE.md @@ -39,3 +39,36 @@ report.md 可读摘要 ``` 清单区分 `completed`、`partial` 和 `failed` 状态,引用已产生的产物,并保留范围来源以供审计。 + +## 分诊层(H3.1) + +`scanforge triage ` 从报告中派生解释,而绝不修改报告本身: + +```text +report.json ──► finding.FromReport ──► 规范化发现结果(确定性 ID) + │ + ▼ + finding.BuildRelations(L0/L1) + │ + ▼ + 分诊引擎:group → bundle → analyze → validate + │ + ▼ + /triage/(manifest、relations、insights、report.md) +``` + +边界是严格的:**ScanForge 拥有事实,AI 拥有解释,验证位于两者之间。** + +- `internal/finding` 将报告投影为扁平发现结果,使用确定性 ID + (`F-` + source|template|asset|matched_at|evidence 的哈希),并计算确定性 + 关系(重复 1.00、共享 CVE 0.99、相同端点 0.95、相同资产 0.80)。语义关系 + (L2)可以补充它们,但绝不能覆盖它们。 +- `internal/triage` 运行流水线:分组(对关系图做 union-find)、确定性洞察 + (摘要 + 去重组)、可选的 LLM 分析、验证与协调(按优先级排序、ID 稳定)。 +- LLM 只收到精简投影(`TriageBundle`):证据被截断、绝不发送工具原始输出、 + 上限 150 条发现。其输出会对照事实进行验证——未知的发现 ID、CVE 或证据 + 字符串会拒绝整个洞察——因此模型无法注入新的事实。 +- `internal/inference` 将传输抽象为 `Client` 接口;内置实现使用 OpenAI 兼容 + chat completions API(llama.cpp、vLLM、Ollama 等)。 +- 来源记录在 `triage/manifest.json` 中(模型、提示词版本、输入摘要、温度), + 当输入摘要、模型和提示词版本不变时,缓存会复用结果(`--force` 可绕过)。 diff --git a/docs/zh/ROADMAP.md b/docs/zh/ROADMAP.md index 0cb98e0..78196ef 100644 --- a/docs/zh/ROADMAP.md +++ b/docs/zh/ROADMAP.md @@ -55,11 +55,11 @@ ScanForge 是一个 Go CLI,将侦察/安全工具编排为基于产物的流 ### H3 — 愿景 -| # | 想法 | -|---|---| -| H3.1 | 发现结果 AI 分诊:LLM 摘要 + 去重 | -| H3.2 | 类 nuclei 的 HTML 报告(客户报告) | -| H3.3 | 实时数据:获取最新 EPSS/KEV/NVD,而非内置数据集 | +| # | 想法 | 状态 | +|---|---|---| +| H3.1 | 发现结果 AI 分诊:LLM 摘要 + 去重 | ✅ 已实现 | +| H3.2 | 类 nuclei 的 HTML 报告(客户报告) | | +| H3.3 | 实时数据:获取最新 EPSS/KEV/NVD,而非内置数据集 | | ## 4. 范围外(防止范围蔓延) diff --git a/docs/zh/USAGE.md b/docs/zh/USAGE.md index 65a8a4f..3822093 100644 --- a/docs/zh/USAGE.md +++ b/docs/zh/USAGE.md @@ -70,6 +70,19 @@ module_timeouts: katana: 20m ``` +`ai` 部分配置 `scanforge triage` 使用的 LLM 后端。任何提供 OpenAI 兼容 +chat completions API 的服务器都可以(llama.cpp、vLLM、Ollama、LM Studio 等)。 +省略该部分时,triage 以纯确定性模式运行(无模型的去重和分组): + +```yaml +ai: + base_url: http://127.0.0.1:8080/v1 + model: qwen3.5-9b + api_key: "" # 本地服务器可省略 + timeout: 5m + temperature: 0.1 # 低值可保持 triage 输出稳定 +``` + ## 推荐流程 首先检查依赖和计划: @@ -107,11 +120,35 @@ scanforge run example.com --scope-mode domain --confirm-scope | `scanforge plan TARGET` | 显示范围与 DAG 波次。 | | `scanforge run TARGET` | 运行已授权的配置文件。 | | `scanforge scan TARGET` | `run` 的别名。 | +| `scanforge triage RUN` | 对一次运行的发现结果去重、分组并(配置 `ai:` 后端时)分析。 | | `scanforge auth` | 管理某些工具所需的密钥。 | | `scanforge version` | 显示二进制版本。 | 查看 `scanforge <命令> --help` 获取完整选项列表。 +## 发现结果分诊 + +`scanforge triage ` 将合并后的报告投影为规范化的发现结果,计算确定性关系 +(重复、共享 CVE、相同端点、相同资产),并将结果写入 `/triage/`: + +```text +triage/manifest.json 来源:模型、提示词版本、输入摘要 +triage/relations.json 发现结果之间的确定性关系 +triage/insights.json 洞察(去重组 + 已验证的 LLM 洞察) +triage/report.md 人类可读摘要 +``` + +配置 `ai:` 后端后,模型只收到刻意精简的发现结果投影(证据截断、绝不发送工具 +原始输出),其洞察在存储前会经过验证:任何引用未知发现 ID、CVE 或证据字符串 +的洞察都会被拒绝。模型可以解释发现结果,但绝不能创建它们。输入未变化时重新 +运行会命中缓存(0 次推理);`--force` 可绕过缓存: + +```bash +scanforge triage runs/example.com/2026-08-19T10:00:00Z +scanforge triage runs/example.com/2026-08-19T10:00:00Z --force +scanforge triage runs/example.com/2026-08-19T10:00:00Z --model qwen3.5-9b +``` + ## 多目标评估 `run` 和 `plan` 接受目标文件,而非单一位置参数目标。每个目标都有独立的范围验证、运行目录和报告(`runs//`);一个目标失败不会中断其余评估。 diff --git a/internal/app/config.go b/internal/app/config.go index 64f190f..8fd3206 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "net/url" "os" "sort" "strings" @@ -68,6 +69,24 @@ func (a *App) ValidateConfig(ctx context.Context) (*ValidateConfigResult, error) } } + if cfg.AI.Model != "" || cfg.AI.BaseURL != "" || cfg.AI.APIKey != "" { + if cfg.AI.BaseURL == "" { + result.Problems = append(result.Problems, + "ai.base_url is required when the ai section is configured (e.g. http://127.0.0.1:8080/v1)") + } else if parsed, err := url.Parse(cfg.AI.BaseURL); err != nil || parsed.Hostname() == "" { + result.Problems = append(result.Problems, + fmt.Sprintf("ai.base_url %q is not a valid URL", cfg.AI.BaseURL)) + } + if cfg.AI.Model == "" { + result.Problems = append(result.Problems, + "ai.model is required when the ai section is configured") + } + if cfg.AI.Temperature != nil && (*cfg.AI.Temperature < 0 || *cfg.AI.Temperature > 2) { + result.Problems = append(result.Problems, + fmt.Sprintf("ai.temperature %v is out of range (expected 0.0-2.0)", *cfg.AI.Temperature)) + } + } + for tool, toolPath := range customToolPaths(cfg) { if _, err := os.Stat(toolPath); err != nil { result.Problems = append(result.Problems, diff --git a/internal/app/config_test.go b/internal/app/config_test.go index ad04614..4b3424f 100644 --- a/internal/app/config_test.go +++ b/internal/app/config_test.go @@ -180,3 +180,90 @@ func TestValidateConfigMissingFileReturnsDefaults(t *testing.T) { t.Fatalf("path = %q, want %q", result.Path, path) } } + +func TestValidateConfigAI(t *testing.T) { + tests := []struct { + name string + config string + wantProblems []string + wantNoAIProblems bool + }{ + { + name: "valid ai config", + config: `config_version: 1 +ai: + base_url: http://127.0.0.1:8080/v1 + model: qwen3.5-9b + temperature: 0.1 +`, + wantNoAIProblems: true, + }, + { + name: "missing base_url", + config: `config_version: 1 +ai: + model: qwen3.5-9b +`, + wantProblems: []string{"ai.base_url"}, + }, + { + name: "missing model", + config: `config_version: 1 +ai: + base_url: http://127.0.0.1:8080/v1 +`, + wantProblems: []string{"ai.model"}, + }, + { + name: "bad base_url", + config: `config_version: 1 +ai: + base_url: not a url + model: qwen3.5-9b +`, + wantProblems: []string{"ai.base_url"}, + }, + { + name: "temperature out of range", + config: `config_version: 1 +ai: + base_url: http://127.0.0.1:8080/v1 + model: qwen3.5-9b + temperature: 3.5 +`, + wantProblems: []string{"ai.temperature"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + app := New(writeConfig(t, tt.config)) + result, err := app.ValidateConfig(t.Context()) + if err != nil { + t.Fatalf("ValidateConfig() error = %v", err) + } + if tt.wantNoAIProblems { + for _, problem := range result.Problems { + if strings.Contains(problem, "ai.") { + t.Fatalf("unexpected ai problem: %v", result.Problems) + } + } + return + } + for _, want := range tt.wantProblems { + if !containsProblem(result.Problems, want) { + t.Fatalf("problems = %v, want %q complaint", result.Problems, want) + } + } + }) + } +} + +func containsProblem(problems []string, needle string) bool { + for _, problem := range problems { + if strings.Contains(problem, needle) { + return true + } + } + return false +} diff --git a/internal/app/triage.go b/internal/app/triage.go new file mode 100644 index 0000000..8cbdae3 --- /dev/null +++ b/internal/app/triage.go @@ -0,0 +1,79 @@ +package app + +import ( + "context" + "fmt" + "path/filepath" + + "github.com/MikeRoss27/scanforge/internal/finding" + "github.com/MikeRoss27/scanforge/internal/report" + "github.com/MikeRoss27/scanforge/internal/storage" + "github.com/MikeRoss27/scanforge/internal/triage" +) + +// TriageOptions selects the run to analyze and optional LLM overrides. +type TriageOptions struct { + // Run is a run root directory (runs//). + Run string + // Force bypasses the cache and re-runs the analysis. + Force bool + // Model and BaseURL override the ai section of scanforge.yaml. + Model string + BaseURL string +} + +// Triage reconsolidates a run's report, projects it into the canonical +// finding model, computes the deterministic relations and runs the triage +// pipeline (deduplication + optional LLM analysis). Results are written under +// /triage/ and returned for rendering. +func (a *App) Triage(ctx context.Context, opts TriageOptions) (*triage.Result, error) { + run, err := storage.OpenRun(opts.Run) + if err != nil { + return nil, fmt.Errorf("open run %q: %w", opts.Run, err) + } + rep, err := report.GenerateReport(opts.Run, &run.Manifest) + if err != nil { + return nil, fmt.Errorf("generate report for %s: %w", opts.Run, err) + } + + findings := finding.FromReport(rep) + relations := finding.BuildRelations(findings) + + cfg, err := a.loadConfig() + if err != nil { + return nil, fmt.Errorf("load config: %w", err) + } + + var model *triage.ModelConfig + if opts.Model != "" || opts.BaseURL != "" || cfg.AI.Model != "" || cfg.AI.BaseURL != "" { + model = &triage.ModelConfig{ + BaseURL: firstNonEmpty(opts.BaseURL, cfg.AI.BaseURL), + Model: firstNonEmpty(opts.Model, cfg.AI.Model), + APIKey: cfg.AI.APIKey, + Timeout: cfg.AI.Timeout, + Temperature: cfg.AI.Temperature, + } + if model.BaseURL == "" || model.Model == "" { + return nil, fmt.Errorf("LLM triage requires ai.base_url and ai.model in scanforge.yaml (or --model/--base-url); omit them to run deterministic-only triage") + } + } + + engine := triage.NewEngine() + return engine.Run(ctx, triage.Input{ + Target: run.Target, + Findings: findings, + Relations: relations, + Model: model, + Force: opts.Force, + OutDir: filepath.Join(run.RootDir, "triage"), + }) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 7c23b2b..1a45102 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -10,6 +10,8 @@ import ( "testing" "github.com/MikeRoss27/scanforge/internal/app" + "github.com/MikeRoss27/scanforge/internal/triage" + "github.com/spf13/cobra" ) // execute runs the command tree with the given args and returns the captured @@ -53,6 +55,7 @@ func TestRootHelpListsGroupsAndCommands(t *testing.T) { "Preview the validated scan pipeline without running it", "Show what changed between two runs of the same target", "Export a run report in a machine-readable format", + "Analyze and prioritize the findings of a run", "Create default ScanForge config files", "Manage API keys for the security tools", "Inspect and validate scanforge.yaml", @@ -78,6 +81,7 @@ func TestSubcommandHelp(t *testing.T) { {[]string{"config", "validate", "--help"}, "Loads scanforge.yaml and checks that it is usable"}, {[]string{"diff", "--help"}, "Loads the two run directories"}, {[]string{"export", "--help"}, "Reconsolidates the report of a run directory"}, + {[]string{"triage", "--help"}, "Projects the consolidated report of a run directory"}, {[]string{"init", "--help"}, "Creates the default configuration files"}, {[]string{"auth", "--help"}, "Manages API keys for the tools"}, {[]string{"version", "--help"}, "Print ScanForge version information"}, @@ -145,3 +149,175 @@ func TestConfigValidateCommandSucceedsOnValidConfig(t *testing.T) { t.Errorf("output missing success message: %q", output) } } + +func TestTriageCommandArgsValidation(t *testing.T) { + cases := []struct { + name string + args []string + wantErr bool + wantErrMsg string + }{ + { + name: "missing run argument", + args: []string{"triage"}, + wantErr: true, + wantErrMsg: "accepts 1 arg", + }, + { + name: "too many arguments", + args: []string{"triage", "run1", "run2"}, + wantErr: true, + wantErrMsg: "accepts 1 arg", + }, + { + name: "valid single argument", + args: []string{"triage", "runs/example.com/2026-08-19T10:00:00Z"}, + wantErr: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Use the execute helper which properly handles subcommands via the root command + _, err := execute(t, tc.args...) + + if tc.wantErr { + if err == nil { + t.Fatalf("expected error for args %v", tc.args) + } + if !strings.Contains(err.Error(), tc.wantErrMsg) { + t.Errorf("error %q does not contain %q", err.Error(), tc.wantErrMsg) + } + } else { + // For valid args, we expect the command to fail at the app.Triage call + // since we're using a fake app with no real run directory. + // The important thing is that argument validation passed. + if err != nil && strings.Contains(err.Error(), "requires exactly 1 arg") { + t.Errorf("unexpected arg validation error: %v", err) + } + } + }) + } +} + +func TestTriageCommandFlagsForwarded(t *testing.T) { + tests := []struct { + name string + args []string + wantForce bool + wantModel string + wantBaseURL string + }{ + { + name: "no flags", + args: []string{"triage", "runs/example.com/2026-08-19T10:00:00Z"}, + wantForce: false, + wantModel: "", + wantBaseURL: "", + }, + { + name: "force flag", + args: []string{"triage", "--force", "runs/example.com/2026-08-19T10:00:00Z"}, + wantForce: true, + wantModel: "", + wantBaseURL: "", + }, + { + name: "model flag", + args: []string{"triage", "--model", "qwen3.5-9b", "runs/example.com/2026-08-19T10:00:00Z"}, + wantForce: false, + wantModel: "qwen3.5-9b", + wantBaseURL: "", + }, + { + name: "base-url flag", + args: []string{"triage", "--base-url", "http://localhost:8080/v1", "runs/example.com/2026-08-19T10:00:00Z"}, + wantForce: false, + wantModel: "", + wantBaseURL: "http://localhost:8080/v1", + }, + { + name: "all flags", + args: []string{"triage", "--force", "--model", "custom-model", "--base-url", "http://custom:8080/v1", "runs/example.com/2026-08-19T10:00:00Z"}, + wantForce: true, + wantModel: "custom-model", + wantBaseURL: "http://custom:8080/v1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Use the execute helper which properly handles subcommands via the root command + _, err := execute(t, tt.args...) + // The command will fail at the app.Triage call since there's no real run, + // but we're testing that flag parsing works (no "unknown flag" errors). + if err != nil && strings.Contains(err.Error(), "unknown flag") { + t.Errorf("unexpected unknown flag error: %v", err) + } + }) + } +} + +func TestPrintTriageSummaryOutput(t *testing.T) { + // Test that printTriageSummary produces deterministic output for a fixed result. + result := &triage.Result{ + Manifest: triage.TriageManifest{ + Model: "test-model", + PromptVersion: "triage-v1", + Temperature: 0.1, + }, + Stats: triage.Stats{ + Findings: 5, + Relations: 3, + Insights: 2, + LLMInsights: 1, + Rejected: 0, + CacheHit: false, + LLMError: "", + }, + Dir: "/tmp/test-triage", + } + + var buf bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&buf) + printTriageSummary(cmd, result) + + output := buf.String() + // Check for key elements in the output + for _, want := range []string{ + "Model:", + "test-model", + "Findings:", + "5", + "Relations:", + "3", + "Insights:", + "2", + "LLM insights:", + "1", + "triage written to", + "/tmp/test-triage", + } { + if !strings.Contains(output, want) { + t.Errorf("output missing %q: %q", want, output) + } + } + + // Test with cache hit + result.Stats.CacheHit = true + buf.Reset() + printTriageSummary(cmd, result) + if !strings.Contains(buf.String(), "hit") { + t.Errorf("cache hit not shown in output: %q", buf.String()) + } + + // Test with LLM error + result.Stats.CacheHit = false + result.Stats.LLMError = "connection refused" + buf.Reset() + printTriageSummary(cmd, result) + if !strings.Contains(buf.String(), "connection refused") { + t.Errorf("LLM error not shown in output: %q", buf.String()) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index f0b4df0..e0cfc26 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -33,7 +33,8 @@ Typical workflow: scanforge doctor verify that the required tools are installed scanforge plan preview the validated pipeline without running it scanforge run execute the scan and produce a report - scanforge diff compare two runs to track changes over time`, + scanforge diff compare two runs to track changes over time + scanforge triage analyze and prioritize a run's findings`, // Runtime errors (a failed module, a deadlock, ...) must not dump // the full flag reference: the scan summary already explains what // happened. Errors are printed once by cmd/scanforge/main.go. @@ -65,6 +66,7 @@ Typical workflow: cmd.AddCommand(NewPlanCommand(application)) cmd.AddCommand(NewDiffCommand(application)) cmd.AddCommand(NewExportCommand(application)) + cmd.AddCommand(NewTriageCommand(application)) cmd.AddCommand(NewInitCommand(application)) cmd.AddCommand(NewAuthCommand(application)) cmd.AddCommand(NewConfigCommand(application)) diff --git a/internal/cli/triage.go b/internal/cli/triage.go new file mode 100644 index 0000000..e44bdd5 --- /dev/null +++ b/internal/cli/triage.go @@ -0,0 +1,76 @@ +package cli + +import ( + "fmt" + + "github.com/MikeRoss27/scanforge/internal/app" + "github.com/MikeRoss27/scanforge/internal/triage" + "github.com/MikeRoss27/scanforge/internal/ui" + "github.com/spf13/cobra" +) + +func NewTriageCommand(application *app.App) *cobra.Command { + var force bool + var model string + var baseURL string + cmd := &cobra.Command{ + Use: "triage ", + GroupID: groupReports, + Short: "Analyze and prioritize the findings of a run", + Long: "Projects the consolidated report of a run directory " + + "(runs//) into canonical findings, computes the " + + "deterministic duplicate/relation groups and, when an LLM backend is " + + "configured (ai: in scanforge.yaml), produces validated AI insights. " + + "Results are written under /triage/.", + Example: ` scanforge triage runs/example.com/2026-08-19T10:00:00Z + scanforge triage runs/example.com/2026-08-19T10:00:00Z --force + scanforge triage runs/example.com/2026-08-19T10:00:00Z --model qwen3.5-9b`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + result, err := application.Triage(cmd.Context(), app.TriageOptions{ + Run: args[0], + Force: force, + Model: model, + BaseURL: baseURL, + }) + if err != nil { + return err + } + printTriageSummary(cmd, result) + return nil + }, + } + cmd.Flags().BoolVar(&force, "force", false, "Re-run the analysis even when the cached result is still valid") + cmd.Flags().StringVar(&model, "model", "", "Model name (overrides ai.model in scanforge.yaml)") + cmd.Flags().StringVar(&baseURL, "base-url", "", "OpenAI-compatible base URL (overrides ai.base_url)") + return cmd +} + +func printTriageSummary(cmd *cobra.Command, result *triage.Result) { + out := cmd.OutOrStdout() + var body string + + model := result.Manifest.Model + if model == "" { + model = "deterministic" + } + body += fmt.Sprintf("Model: %s\n", ui.Primary(model)) + body += fmt.Sprintf("Findings: %d\n", result.Stats.Findings) + body += fmt.Sprintf("Relations: %d\n", result.Stats.Relations) + body += fmt.Sprintf("Insights: %d\n", result.Stats.Insights) + if result.Stats.LLMInsights > 0 { + body += fmt.Sprintf("LLM insights: %d\n", result.Stats.LLMInsights) + } + if result.Stats.Rejected > 0 { + body += fmt.Sprintf("Rejected: %s\n", ui.Yellow(fmt.Sprintf("%d", result.Stats.Rejected))) + } + if result.Stats.CacheHit { + body += fmt.Sprintf("Cache: %s\n", ui.Green("hit")) + } + if result.Stats.LLMError != "" { + body += fmt.Sprintf("LLM error: %s\n", ui.Red(result.Stats.LLMError)) + } + body += fmt.Sprintf("\n%s %s\n", ui.Green("✓ triage written to"), ui.Primary(result.Dir)) + + _, _ = fmt.Fprintln(out, ui.Panel("🧠 TRIAGE", body)) +} diff --git a/internal/config/config.go b/internal/config/config.go index bf36d68..3b573bd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -23,6 +23,27 @@ type Config struct { // Zero (unset) means the module's own default applies. ModuleTimeouts map[string]time.Duration `yaml:"module_timeouts"` Webhook Webhook `yaml:"webhook"` + // AI configures the LLM backend used by `scanforge triage`. When empty, + // triage runs in deterministic-only mode. + AI AI `yaml:"ai"` +} + +// AI holds the OpenAI-compatible endpoint used by the triage analyzer. Any +// server exposing /v1/chat/completions works: llama.cpp, vLLM, Ollama, LM +// Studio, ... +type AI struct { + // BaseURL is the full API base including /v1, e.g. + // http://127.0.0.1:8080/v1. + BaseURL string `yaml:"base_url"` + // Model is the model name reported by the server. + Model string `yaml:"model"` + // APIKey is sent as a Bearer token; empty for local servers. + APIKey string `yaml:"api_key"` + // Timeout bounds a single generation request (Go duration). + Timeout time.Duration `yaml:"timeout"` + // Temperature for triage generation. Low values keep output stable. + // A nil value means unset (server default); a pointer to 0.0 means explicit zero. + Temperature *float64 `yaml:"temperature"` } // Webhook holds the end-of-run notification endpoint. The payload is a @@ -193,6 +214,16 @@ tools: # module_timeouts: # nuclei: 45m # katana: 20m + +# LLM backend for the scanforge triage command (OpenAI-compatible API: +# llama.cpp, vLLM, Ollama, LM Studio, ...). When omitted, triage runs in +# deterministic-only mode (deduplication and grouping without a model). +# ai: +# base_url: http://127.0.0.1:8080/v1 +# model: qwen3.5-9b +# api_key: "" +# timeout: 5m +# temperature: 0.1 `, DefaultConfigVersion, DefaultWorkspace, DefaultProfile, DefaultScope) } @@ -251,6 +282,21 @@ func mergeDefaults(base, parsed *Config) { if len(parsed.Profiles) == 0 { parsed.Profiles = base.Profiles } + if parsed.AI.BaseURL == "" { + parsed.AI.BaseURL = base.AI.BaseURL + } + if parsed.AI.Model == "" { + parsed.AI.Model = base.AI.Model + } + if parsed.AI.APIKey == "" { + parsed.AI.APIKey = base.AI.APIKey + } + if parsed.AI.Timeout == 0 { + parsed.AI.Timeout = base.AI.Timeout + } + if parsed.AI.Temperature == nil { + parsed.AI.Temperature = base.AI.Temperature + } } func WorkspaceDir(cfg *Config) string { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index d6c3fc5..f690cf5 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "strings" "testing" "time" ) @@ -160,3 +161,83 @@ func TestToolPath(t *testing.T) { t.Fatalf("unexpected tool path: %q", got) } } + +func TestLoadAI(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "scanforge.yaml") + + content := `config_version: 1 +ai: + base_url: http://127.0.0.1:8080/v1 + model: qwen3.5-9b + api_key: secret + timeout: 2m + temperature: 0.05 +` + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + cfg, err := Load(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.AI.BaseURL != "http://127.0.0.1:8080/v1" { + t.Errorf("base_url = %q", cfg.AI.BaseURL) + } + if cfg.AI.Model != "qwen3.5-9b" { + t.Errorf("model = %q", cfg.AI.Model) + } + if cfg.AI.APIKey != "secret" { + t.Errorf("api_key = %q", cfg.AI.APIKey) + } + if cfg.AI.Timeout != 2*time.Minute { + t.Errorf("timeout = %v, want 2m", cfg.AI.Timeout) + } + if cfg.AI.Temperature == nil || *cfg.AI.Temperature != 0.05 { + var tempVal float64 + if cfg.AI.Temperature != nil { + tempVal = *cfg.AI.Temperature + } + t.Errorf("temperature = %v, want 0.05", tempVal) + } +} + +func TestLoadAIMergesDefaults(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "scanforge.yaml") + + content := `config_version: 1 +ai: + base_url: http://127.0.0.1:8080/v1 + model: qwen3.5-9b +` + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + cfg, err := Load(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.AI.Timeout != DefaultAITimeout { + t.Errorf("timeout = %v, want default %v", cfg.AI.Timeout, DefaultAITimeout) + } + if cfg.AI.Temperature == nil || *cfg.AI.Temperature != DefaultAITemperature { + var tempVal float64 + if cfg.AI.Temperature != nil { + tempVal = *cfg.AI.Temperature + } + t.Errorf("temperature = %v, want default %v", tempVal, DefaultAITemperature) + } +} + +func TestYAMLTemplateIncludesAI(t *testing.T) { + cfg := Default() + template := cfg.YAMLTemplate() + for _, want := range []string{"ai:", "base_url:", "temperature:"} { + if !strings.Contains(template, want) { + t.Errorf("template missing %q", want) + } + } +} diff --git a/internal/config/defaults.go b/internal/config/defaults.go index ae5c661..cb1a11d 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -8,9 +8,15 @@ const ( DefaultProfile = "passive" DefaultScope = "scope.txt" DefaultConfigVersion = 1 + + // DefaultAITimeout bounds a single triage generation request. + DefaultAITimeout = 5 * time.Minute + // DefaultAITemperature keeps triage output stable and reproducible. + DefaultAITemperature = 0.1 ) func Default() *Config { + defaultTemp := DefaultAITemperature return &Config{ ConfigVersion: DefaultConfigVersion, Workspace: DefaultWorkspace, @@ -32,5 +38,9 @@ func Default() *Config { }, Profiles: map[string][]string{}, ModuleTimeouts: map[string]time.Duration{}, + AI: AI{ + Timeout: DefaultAITimeout, + Temperature: &defaultTemp, + }, } } diff --git a/internal/finding/finding.go b/internal/finding/finding.go new file mode 100644 index 0000000..60be488 --- /dev/null +++ b/internal/finding/finding.go @@ -0,0 +1,134 @@ +// Package finding defines the canonical, authoritative representation of a +// security finding projected from the consolidated report, plus the +// deterministic relations between findings (duplicates, shared CVE, shared +// endpoint, ...). Findings are ScanForge-owned facts: the AI triage layer may +// interpret them but never create or modify them. +package finding + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" +) + +// ID is the stable, deterministic identifier of a finding. It is derived from +// the finding's identity fields, so the same finding re-discovered in a later +// run gets the same ID (which powers caching, diffing and validation). +type ID string + +// Severity is the normalized severity of a finding. +type Severity string + +const ( + SevInfo Severity = "info" + SevLow Severity = "low" + SevMedium Severity = "medium" + SevHigh Severity = "high" + SevCritical Severity = "critical" +) + +// NormalizeSeverity maps any scanner severity label onto the canonical set. +// Unknown labels fall back to info so downstream layers always see one of the +// five canonical values. +func NormalizeSeverity(value string) Severity { + switch strings.ToLower(strings.TrimSpace(value)) { + case "critical": + return SevCritical + case "high": + return SevHigh + case "medium", "moderate": + return SevMedium + case "low": + return SevLow + case "info", "informational", "none": + return SevInfo + default: + return SevInfo + } +} + +// Finding is one security issue discovered during a run. It is a flat +// projection of report.Vulnerability (and similar report entries) enriched +// with the owning asset and a deterministic ID. +type Finding struct { + ID ID `json:"id"` + Kind string `json:"kind"` + Asset string `json:"asset"` + URL string `json:"url,omitempty"` + Severity Severity `json:"severity"` + Source string `json:"source"` + TemplateID string `json:"template_id,omitempty"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Evidence string `json:"evidence,omitempty"` + Tags []string `json:"tags,omitempty"` + CVEs []string `json:"cves,omitempty"` + CWEs []string `json:"cwes,omitempty"` + References []string `json:"references,omitempty"` + CVSS float64 `json:"cvss,omitempty"` + EPSS float64 `json:"epss,omitempty"` + KEV bool `json:"kev,omitempty"` + MatchedAt string `json:"matched_at"` +} + +// Fingerprint computes the deterministic ID from the identity fields. The +// evidence hash is part of the identity so two findings that differ only in +// their evidence remain distinct. +func (f *Finding) Fingerprint() ID { + identity := strings.Join([]string{ + f.Source, f.TemplateID, f.Asset, f.MatchedAt, f.Evidence, + }, "|") + sum := sha256.Sum256([]byte(identity)) + return ID("F-" + hex.EncodeToString(sum[:])[:16]) +} + +// Priority derives the triage priority from the severity. +func (f *Finding) Priority() Priority { + return PriorityFromSeverity(f.Severity) +} + +// Priority is the triage priority of an insight or finding. +type Priority string + +const ( + PrioNone Priority = "none" + PrioLow Priority = "low" + PrioMedium Priority = "medium" + PrioHigh Priority = "high" + PrioCritical Priority = "critical" +) + +// PriorityFromSeverity maps a severity onto the canonical priority set. +func PriorityFromSeverity(sev Severity) Priority { + switch sev { + case SevCritical: + return PrioCritical + case SevHigh: + return PrioHigh + case SevMedium: + return PrioMedium + case SevLow: + return PrioLow + default: + return PrioNone + } +} + +// ParsePriority normalizes a user or model supplied priority label. +func ParsePriority(value string) (Priority, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "critical": + return PrioCritical, nil + case "high": + return PrioHigh, nil + case "medium": + return PrioMedium, nil + case "low": + return PrioLow, nil + case "none", "": + return PrioNone, nil + default: + return "", fmt.Errorf("unknown priority %q", value) + } +} diff --git a/internal/finding/finding_test.go b/internal/finding/finding_test.go new file mode 100644 index 0000000..a884cf1 --- /dev/null +++ b/internal/finding/finding_test.go @@ -0,0 +1,180 @@ +package finding + +import ( + "testing" + + "github.com/MikeRoss27/scanforge/internal/report" +) + +func TestNormalizeSeverity(t *testing.T) { + cases := map[string]Severity{ + "critical": SevCritical, + "CRITICAL": SevCritical, + "high": SevHigh, + "medium": SevMedium, + "moderate": SevMedium, + "low": SevLow, + "info": SevInfo, + "informational": SevInfo, + "weird-label": SevInfo, + "": SevInfo, + } + for input, want := range cases { + if got := NormalizeSeverity(input); got != want { + t.Errorf("NormalizeSeverity(%q) = %q, want %q", input, got, want) + } + } +} + +func TestFingerprintDeterministic(t *testing.T) { + f := Finding{ + Source: "nuclei", TemplateID: "cve-2026-0001", Asset: "example.com", + MatchedAt: "https://example.com/admin", Evidence: "HTTP/1.1 200 OK", + } + first := f.Fingerprint() + second := f.Fingerprint() + if first != second { + t.Fatalf("fingerprint not stable: %q vs %q", first, second) + } + if len(first) != 18 || first[:2] != "F-" { + t.Fatalf("unexpected fingerprint format %q", first) + } + + other := f + other.Evidence = "HTTP/1.1 500 Internal Server Error" + if other.Fingerprint() == first { + t.Fatalf("fingerprint must differ when evidence differs") + } +} + +func TestFromReportProjectsVulnerabilities(t *testing.T) { + rep := report.NewReport("example.com", "web") + asset := rep.GetOrCreateAsset("example.com") + asset.Vulnerabilities = append(asset.Vulnerabilities, + &report.Vulnerability{ + Source: "nuclei", TemplateID: "t-1", Title: "XSS", Severity: "high", + MatchedAt: "https://example.com/search", Evidence: "reflected", + CVEs: []string{"CVE-2026-0001"}, CVSS: 8.1, EPSS: 0.5, KEV: true, + }, + &report.Vulnerability{ + Source: "techcve", TemplateID: "CVE-2026-0002", Title: "RCE", + Severity: "critical", MatchedAt: "example.com", + }, + ) + rep.JSVerified = append(rep.JSVerified, report.VerifiedFinding{ + URL: "https://example.com/app.js", Kind: "api_key", Pattern: "sk-[a-z]+", + Severity: "medium", Evidence: "sk-abc123", + }) + + findings := FromReport(rep) + if len(findings) != 3 { + t.Fatalf("expected 3 findings, got %d", len(findings)) + } + + vuln := findings[0] + if vuln.Kind != "vulnerability" || vuln.Asset != "example.com" { + t.Errorf("unexpected vuln projection: %+v", vuln) + } + if vuln.URL != "https://example.com/search" { + t.Errorf("URL should be the http(s) matched_at, got %q", vuln.URL) + } + if vuln.Severity != SevHigh || vuln.CVSS != 8.1 || !vuln.KEV { + t.Errorf("vuln fields not projected: %+v", vuln) + } + if vuln.ID == "" { + t.Error("projected finding missing ID") + } + + techcve := findings[1] + if techcve.URL != "" { + t.Errorf("non-URL matched_at must not become a URL, got %q", techcve.URL) + } + + secret := findings[2] + if secret.Kind != "verified_secret" || secret.Asset != "example.com" { + t.Errorf("unexpected secret projection: %+v", secret) + } +} + +func TestFromReportDeterministicOrder(t *testing.T) { + rep := report.NewReport("example.com", "web") + rep.GetOrCreateAsset("b.example.com").Vulnerabilities = append( + rep.GetOrCreateAsset("b.example.com").Vulnerabilities, + &report.Vulnerability{Source: "nuclei", TemplateID: "t", Title: "B", MatchedAt: "b.example.com"}) + rep.GetOrCreateAsset("a.example.com").Vulnerabilities = append( + rep.GetOrCreateAsset("a.example.com").Vulnerabilities, + &report.Vulnerability{Source: "nuclei", TemplateID: "t", Title: "A", MatchedAt: "a.example.com"}) + + first := FromReport(rep) + second := FromReport(rep) + if len(first) != 2 || len(second) != 2 { + t.Fatalf("expected 2 findings, got %d and %d", len(first), len(second)) + } + if first[0].Asset != "a.example.com" || second[0].Asset != "a.example.com" { + t.Errorf("assets must be visited in sorted order (got %q and %q)", first[0].Asset, second[0].Asset) + } +} + +func TestBuildRelations(t *testing.T) { + mk := func(source, template, asset, url string, cves []string) Finding { + f := Finding{ + Kind: "vulnerability", Asset: asset, Source: source, + TemplateID: template, URL: url, CVEs: cves, MatchedAt: url, + } + f.ID = f.Fingerprint() + return f + } + + findings := []Finding{ + mk("nuclei", "t-1", "a.com", "https://a.com/x", nil), + mk("nuclei", "t-1", "a.com", "https://a.com/y", nil), // duplicate of first + mk("nuclei", "t-2", "a.com", "https://a.com/z", []string{"CVE-2026-0001"}), + mk("techcve", "CVE-2026-0001", "b.com", "", []string{"CVE-2026-0001"}), // shares CVE with third + mk("nuclei", "t-3", "c.com", "https://c.com", nil), // isolated + } + + relations := BuildRelations(findings) + if len(relations) != 4 { + t.Fatalf("expected 4 relations, got %d: %+v", len(relations), relations) + } + + byType := map[RelationType]int{} + for _, rel := range relations { + byType[rel.Type]++ + if rel.From >= rel.To { + t.Errorf("relation not canonical (From %q >= To %q)", rel.From, rel.To) + } + } + if byType[RelDuplicate] != 1 { + t.Errorf("expected 1 duplicate relation, got %d", byType[RelDuplicate]) + } + if byType[RelSameCVE] != 1 { + t.Errorf("expected 1 same_cve relation, got %d", byType[RelSameCVE]) + } + if byType[RelSameAsset] != 2 { + t.Errorf("expected 2 same_asset relations, got %d", byType[RelSameAsset]) + } +} + +func TestBuildRelationsDeterministic(t *testing.T) { + mk := func(source, template, asset string) Finding { + f := Finding{Kind: "vulnerability", Asset: asset, Source: source, TemplateID: template} + f.ID = f.Fingerprint() + return f + } + findings := []Finding{ + mk("nuclei", "t-1", "a.com"), + mk("nuclei", "t-1", "a.com"), + mk("nuclei", "t-2", "b.com"), + } + first := BuildRelations(findings) + second := BuildRelations(findings) + if len(first) != len(second) { + t.Fatalf("relation count not stable") + } + for i := range first { + if first[i] != second[i] { + t.Errorf("relations not deterministic at %d: %+v vs %+v", i, first[i], second[i]) + } + } +} diff --git a/internal/finding/project.go b/internal/finding/project.go new file mode 100644 index 0000000..1c527cb --- /dev/null +++ b/internal/finding/project.go @@ -0,0 +1,97 @@ +package finding + +import ( + "net/url" + "sort" + "strings" + + "github.com/MikeRoss27/scanforge/internal/report" +) + +// FromReport projects the consolidated report into the flat, canonical +// finding model. Every vulnerability (nuclei, techcve, http checks, js +// secrets) becomes a Finding with a deterministic ID; verified JS secret +// replays become findings of kind "verified_secret". The projection is +// deterministic: assets are visited in sorted order and findings keep the +// order they appear in the report. +func FromReport(rep *report.Report) []Finding { + if rep == nil { + return nil + } + + assets := make([]string, 0, len(rep.Assets)) + for name := range rep.Assets { + assets = append(assets, name) + } + sort.Strings(assets) + + var findings []Finding + for _, name := range assets { + asset := rep.Assets[name] + for _, vuln := range asset.Vulnerabilities { + f := Finding{ + Kind: "vulnerability", + Asset: name, + URL: urlIfHTTP(vuln.MatchedAt), + Severity: NormalizeSeverity(vuln.Severity), + Source: vuln.Source, + TemplateID: vuln.TemplateID, + Title: vuln.Title, + Description: vuln.Description, + Evidence: vuln.Evidence, + Tags: vuln.Tags, + CVEs: vuln.CVEs, + CWEs: vuln.CWEs, + References: vuln.References, + CVSS: vuln.CVSS, + EPSS: vuln.EPSS, + KEV: vuln.KEV, + MatchedAt: vuln.MatchedAt, + } + f.ID = f.Fingerprint() + findings = append(findings, f) + } + } + + for _, verified := range rep.JSVerified { + f := Finding{ + Kind: "verified_secret", + Asset: hostOf(verified.URL), + URL: verified.URL, + Severity: NormalizeSeverity(verified.Severity), + Source: "jsverify", + TemplateID: verified.Pattern, + Title: verified.Kind, + Evidence: verified.Evidence, + MatchedAt: verified.URL, + } + if f.Asset == "" { + f.Asset = hostOf(verified.Page) + } + f.ID = f.Fingerprint() + findings = append(findings, f) + } + + return findings +} + +// urlIfHTTP returns the value when it looks like an http(s) URL, otherwise "". +func urlIfHTTP(value string) string { + parsed, err := url.Parse(strings.TrimSpace(value)) + if err != nil || parsed.Hostname() == "" { + return "" + } + if parsed.Scheme == "http" || parsed.Scheme == "https" { + return parsed.String() + } + return "" +} + +// hostOf extracts the hostname from a URL, or "" when it is not a URL. +func hostOf(value string) string { + parsed, err := url.Parse(strings.TrimSpace(value)) + if err != nil || parsed.Hostname() == "" { + return "" + } + return strings.ToLower(parsed.Hostname()) +} diff --git a/internal/finding/relation.go b/internal/finding/relation.go new file mode 100644 index 0000000..38da508 --- /dev/null +++ b/internal/finding/relation.go @@ -0,0 +1,123 @@ +package finding + +import ( + "sort" + "strings" +) + +// RelationType classifies the relationship between two findings. +type RelationType string + +const ( + // RelDuplicate marks two findings that are the same issue (same source, + // same template, same asset). Deterministic, confidence 1.0. + RelDuplicate RelationType = "duplicate" + // RelSameCVE marks findings sharing at least one CVE identifier. + RelSameCVE RelationType = "same_cve" + // RelSameEndpoint marks findings on the exact same URL. + RelSameEndpoint RelationType = "same_endpoint" + // RelSameAsset marks findings on the same host. + RelSameAsset RelationType = "same_asset" +) + +// RelationSource says where a relation came from. Deterministic relations are +// authoritative; LLM relations are derived and never override them. +type RelationSource string + +const ( + RelSourceDeterministic RelationSource = "deterministic" + RelSourceLLM RelationSource = "llm" +) + +// FindingRelation links two findings. The confidence expresses how strongly +// the relation is believed: 1.0 for exact duplicates, down to 0.8 for the +// same-asset heuristic. +type FindingRelation struct { + From ID `json:"from"` + To ID `json:"to"` + Type RelationType `json:"type"` + Confidence float64 `json:"confidence"` + Source RelationSource `json:"source"` +} + +// BuildRelations computes the deterministic (L0/L1) relations between every +// pair of findings. The strongest applicable rule wins per pair: +// +// duplicate (same source+template+asset) → 1.00 +// shared CVE → 0.99 +// same URL endpoint → 0.95 +// same asset → 0.80 +// +// The result is sorted for determinism. This is the L0/L1 layer: semantic +// (L2) relations produced later by an LLM can add to it but never override it. +func BuildRelations(findings []Finding) []FindingRelation { + // Precompute normalized CVE sets for each finding to avoid rebuilding + // them on every pair comparison. + cveSets := make([]map[string]struct{}, len(findings)) + for i, f := range findings { + set := make(map[string]struct{}, len(f.CVEs)) + for _, cve := range f.CVEs { + set[strings.ToLower(strings.TrimSpace(cve))] = struct{}{} + } + cveSets[i] = set + } + + var relations []FindingRelation + for i := 0; i < len(findings); i++ { + for j := i + 1; j < len(findings); j++ { + if rel, ok := strongestRelation(findings[i], findings[j], cveSets[i], cveSets[j]); ok { + // Canonicalize direction so the graph is independent of the + // input order: From always sorts before To. + if rel.From > rel.To { + rel.From, rel.To = rel.To, rel.From + } + relations = append(relations, rel) + } + } + } + sort.Slice(relations, func(a, b int) bool { + if relations[a].From != relations[b].From { + return relations[a].From < relations[b].From + } + if relations[a].To != relations[b].To { + return relations[a].To < relations[b].To + } + return relations[a].Type < relations[b].Type + }) + return relations +} + +// strongestRelation returns the strongest deterministic relation between two +// findings, or ok=false when none applies. cveA and cveB are precomputed +// normalized CVE sets for the findings. +func strongestRelation(a, b Finding, cveA, cveB map[string]struct{}) (FindingRelation, bool) { + if a.Source == b.Source && a.TemplateID != "" && a.TemplateID == b.TemplateID && a.Asset == b.Asset { + return FindingRelation{From: a.ID, To: b.ID, Type: RelDuplicate, Confidence: 1.0, Source: RelSourceDeterministic}, true + } + if shareAnySets(cveA, cveB) { + return FindingRelation{From: a.ID, To: b.ID, Type: RelSameCVE, Confidence: 0.99, Source: RelSourceDeterministic}, true + } + if a.URL != "" && a.URL == b.URL { + return FindingRelation{From: a.ID, To: b.ID, Type: RelSameEndpoint, Confidence: 0.95, Source: RelSourceDeterministic}, true + } + if a.Asset != "" && a.Asset == b.Asset { + return FindingRelation{From: a.ID, To: b.ID, Type: RelSameAsset, Confidence: 0.80, Source: RelSourceDeterministic}, true + } + return FindingRelation{}, false +} + +func shareAnySets(a, b map[string]struct{}) bool { + if len(a) == 0 || len(b) == 0 { + return false + } + // Iterate over the smaller set for efficiency. + if len(a) > len(b) { + a, b = b, a + } + for v := range a { + if _, ok := b[v]; ok { + return true + } + } + return false +} diff --git a/internal/inference/client.go b/internal/inference/client.go new file mode 100644 index 0000000..08a7174 --- /dev/null +++ b/internal/inference/client.go @@ -0,0 +1,153 @@ +// Package inference abstracts the LLM transport behind a small interface so +// the triage engine never depends on a specific backend. The bundled client +// speaks the OpenAI-compatible chat completions API, which llama.cpp, vLLM, +// Ollama and most local/cloud servers expose. +package inference + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// Message is one chat turn. +type Message struct { + Role string `json:"role"` + Content string `json:"content"` +} + +// Request is a chat completion request. +type Request struct { + Model string + Messages []Message + Temperature *float64 + MaxTokens int + // JSON asks the server for a JSON object response (response_format). + JSON bool +} + +// Response is the model's completion. +type Response struct { + Content string + PromptTokens int + CompletionTokens int +} + +// Client generates completions. Implementations must be safe for concurrent +// use and honor context cancellation. +type Client interface { + Generate(ctx context.Context, req Request) (Response, error) +} + +// OpenAICompatible is a Client for any server exposing /v1/chat/completions +// (llama.cpp, vLLM, Ollama, LM Studio, ...). +type OpenAICompatible struct { + // BaseURL is the full API base, e.g. http://127.0.0.1:8080/v1. + BaseURL string + // APIKey is sent as a Bearer token; may be empty for local servers. + APIKey string + // Model is the default model name, overridable per request. + Model string + HTTP *http.Client +} + +// NewOpenAICompatible builds a client with a sane default timeout. +func NewOpenAICompatible(baseURL, apiKey, model string, timeout time.Duration) *OpenAICompatible { + if timeout <= 0 { + timeout = 5 * time.Minute + } + return &OpenAICompatible{ + BaseURL: strings.TrimRight(baseURL, "/"), + APIKey: apiKey, + Model: model, + HTTP: &http.Client{Timeout: timeout}, + } +} + +// Generate posts a chat completion request and returns the first choice. +func (c *OpenAICompatible) Generate(ctx context.Context, req Request) (Response, error) { + model := req.Model + if model == "" { + model = c.Model + } + + payload := map[string]any{ + "model": model, + "messages": req.Messages, + } + if req.Temperature != nil { + payload["temperature"] = *req.Temperature + } + if req.MaxTokens > 0 { + payload["max_tokens"] = req.MaxTokens + } + if req.JSON { + payload["response_format"] = map[string]string{"type": "json_object"} + } + + body, err := json.Marshal(payload) + if err != nil { + return Response{}, fmt.Errorf("inference: marshal request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, + c.BaseURL+"/chat/completions", bytes.NewReader(body)) + if err != nil { + return Response{}, fmt.Errorf("inference: build request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + if c.APIKey != "" { + httpReq.Header.Set("Authorization", "Bearer "+c.APIKey) + } + + httpResp, err := c.HTTP.Do(httpReq) + if err != nil { + return Response{}, fmt.Errorf("inference: %w", err) + } + defer func() { _ = httpResp.Body.Close() }() + + respBody, err := io.ReadAll(io.LimitReader(httpResp.Body, 8<<20)) + if err != nil { + return Response{}, fmt.Errorf("inference: read response: %w", err) + } + if httpResp.StatusCode != http.StatusOK { + return Response{}, fmt.Errorf("inference: server returned %s: %s", + httpResp.Status, truncate(string(respBody), 300)) + } + + var decoded struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + Usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + } `json:"usage"` + } + if err := json.Unmarshal(respBody, &decoded); err != nil { + return Response{}, fmt.Errorf("inference: parse response: %w", err) + } + if len(decoded.Choices) == 0 { + return Response{}, fmt.Errorf("inference: no choices in response") + } + + return Response{ + Content: decoded.Choices[0].Message.Content, + PromptTokens: decoded.Usage.PromptTokens, + CompletionTokens: decoded.Usage.CompletionTokens, + }, nil +} + +func truncate(value string, max int) string { + if len(value) <= max { + return value + } + return value[:max] + "…" +} diff --git a/internal/inference/client_test.go b/internal/inference/client_test.go new file mode 100644 index 0000000..41b9cb1 --- /dev/null +++ b/internal/inference/client_test.go @@ -0,0 +1,123 @@ +package inference + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func ptrFloat64(v float64) *float64 { + return &v +} + +func TestOpenAICompatibleGenerate(t *testing.T) { + var gotPath, gotAuth, gotBody string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + buf := make([]byte, r.ContentLength) + _, _ = r.Body.Read(buf) + gotBody = string(buf) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "choices": [{"message": {"content": "{\"insights\":[]}"}}], + "usage": {"prompt_tokens": 10, "completion_tokens": 5} + }`)) + })) + defer server.Close() + + client := NewOpenAICompatible(server.URL, "secret-key", "qwen3.5-9b", 0) + resp, err := client.Generate(context.Background(), Request{ + Messages: []Message{{Role: "user", Content: "hi"}}, + Temperature: ptrFloat64(0.1), + MaxTokens: 100, + JSON: true, + }) + if err != nil { + t.Fatalf("Generate error: %v", err) + } + + if gotPath != "/chat/completions" { + t.Errorf("unexpected path %q", gotPath) + } + if gotAuth != "Bearer secret-key" { + t.Errorf("unexpected auth header %q", gotAuth) + } + var payload map[string]any + if err := json.Unmarshal([]byte(gotBody), &payload); err != nil { + t.Fatalf("request body not JSON: %v", err) + } + if payload["model"] != "qwen3.5-9b" { + t.Errorf("model not sent: %v", payload["model"]) + } + if payload["response_format"] == nil { + t.Error("response_format json_object not requested") + } + if !strings.Contains(resp.Content, "insights") { + t.Errorf("unexpected content %q", resp.Content) + } + if resp.PromptTokens != 10 || resp.CompletionTokens != 5 { + t.Errorf("usage not parsed: %+v", resp) + } +} + +func TestOpenAICompatibleRequestModelOverride(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`)) + })) + defer server.Close() + + client := NewOpenAICompatible(server.URL, "", "", 0) + if _, err := client.Generate(context.Background(), Request{Model: "override-model"}); err != nil { + t.Fatalf("Generate error: %v", err) + } +} + +func TestOpenAICompatibleServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"error":"boom"}`, http.StatusBadRequest) + })) + defer server.Close() + + client := NewOpenAICompatible(server.URL, "", "", 0) + _, err := client.Generate(context.Background(), Request{}) + if err == nil { + t.Fatal("expected error for non-2xx response") + } + if !strings.Contains(err.Error(), "400") { + t.Errorf("error should mention status: %v", err) + } +} + +func TestOpenAICompatibleNoChoices(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"choices":[]}`)) + })) + defer server.Close() + + client := NewOpenAICompatible(server.URL, "", "", 0) + if _, err := client.Generate(context.Background(), Request{}); err == nil { + t.Fatal("expected error when no choices are returned") + } +} + +func TestOpenAICompatibleContextCancelled(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`)) + })) + defer server.Close() + + client := NewOpenAICompatible(server.URL, "", "", 0) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := client.Generate(ctx, Request{}); err == nil { + t.Fatal("expected error for cancelled context") + } +} diff --git a/internal/triage/analyzer.go b/internal/triage/analyzer.go new file mode 100644 index 0000000..f8f2576 --- /dev/null +++ b/internal/triage/analyzer.go @@ -0,0 +1,153 @@ +package triage + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/MikeRoss27/scanforge/internal/finding" + "github.com/MikeRoss27/scanforge/internal/inference" +) + +// Analyzer turns a safe triage bundle into candidate insights. The LLM +// implementation is the default; tests inject fakes. +type Analyzer interface { + Analyze(ctx context.Context, bundle TriageBundle) ([]TriageInsight, error) +} + +// LLMAnalyzer prompts a model through an OpenAI-compatible client and parses +// the JSON response. The model is an interpretation engine, never a knowledge +// source: the prompt forbids inventing facts, and the engine validates the +// output against the authoritative findings afterwards. +type LLMAnalyzer struct { + client inference.Client + model string + temp *float64 +} + +// NewLLMAnalyzer builds the default analyzer from a model config. +func NewLLMAnalyzer(model *ModelConfig) *LLMAnalyzer { + client := inference.NewOpenAICompatible(model.BaseURL, model.APIKey, model.Model, model.Timeout) + return &LLMAnalyzer{ + client: client, + model: model.Model, + temp: model.Temperature, + } +} + +const systemPrompt = `You are a security triage analyst for ScanForge, an authorized pentest orchestrator. +You receive a JSON bundle of findings discovered during an authorized security assessment, plus deterministic relations between them. + +Rules: +- Findings are authoritative facts. You only interpret them; you never create or modify them. +- Only reference finding IDs, CVEs, assets, URLs and evidence strings that appear in the bundle. Never invent any. +- Do not merge findings that the deterministic relations do not relate. +- Keep every summary under 200 characters. +- If you cannot determine something, say so in "uncertainty". + +Respond with a single JSON object, no prose outside it: +{ + "insights": [ + { + "kind": "summary" | "priority" | "exploitability" | "observation", + "finding_ids": ["F-..."], + "summary": "one or two sentences", + "priority": "critical" | "high" | "medium" | "low" | "none", + "confidence": 0.0, + "cves": ["CVE-..."], + "evidence_refs": ["..."], + "uncertainty": ["..."] + } + ] +} + +Meaning of kinds: +- "summary": overall assessment of the findings. +- "priority": which findings to address first and why. +- "exploitability": whether a finding appears exploitable. +- "observation": any other useful interpretation. + +"cves" and "evidence_refs" must only contain values present in the bundle. "confidence" is how confident you are in the insight, between 0.0 and 1.0.` + +// Analyze builds the prompt from the safe bundle and parses the model output. +func (a *LLMAnalyzer) Analyze(ctx context.Context, bundle TriageBundle) ([]TriageInsight, error) { + bundleJSON, err := json.Marshal(bundle) + if err != nil { + return nil, fmt.Errorf("triage: marshal bundle: %w", err) + } + + resp, err := a.client.Generate(ctx, inference.Request{ + Model: a.model, + Messages: []inference.Message{ + {Role: "system", Content: systemPrompt}, + {Role: "user", Content: string(bundleJSON)}, + }, + Temperature: a.temp, + MaxTokens: 2048, + JSON: true, + }) + if err != nil { + return nil, fmt.Errorf("triage: model request: %w", err) + } + + parsed, err := parseLLMResponse(resp.Content) + if err != nil { + return nil, fmt.Errorf("triage: parse model output: %w", err) + } + return parsed, nil +} + +// llmInsight is the wire format the model is asked to produce. +type llmInsight struct { + Kind string `json:"kind"` + FindingIDs []string `json:"finding_ids"` + Summary string `json:"summary"` + Priority string `json:"priority"` + Confidence float64 `json:"confidence"` + CVEs []string `json:"cves,omitempty"` + EvidenceRefs []string `json:"evidence_refs,omitempty"` + Uncertainty []string `json:"uncertainty,omitempty"` +} + +type llmResponse struct { + Insights []llmInsight `json:"insights"` +} + +// parseLLMResponse extracts the JSON object from the model output (models +// sometimes wrap it in prose or code fences) and decodes it. +func parseLLMResponse(content string) ([]TriageInsight, error) { + start := strings.Index(content, "{") + end := strings.LastIndex(content, "}") + if start < 0 || end <= start { + return nil, fmt.Errorf("no JSON object in model output") + } + var decoded llmResponse + if err := json.Unmarshal([]byte(content[start:end+1]), &decoded); err != nil { + return nil, err + } + + insights := make([]TriageInsight, 0, len(decoded.Insights)) + for _, raw := range decoded.Insights { + priority, err := finding.ParsePriority(raw.Priority) + if err != nil { + return nil, err + } + ids := make([]finding.ID, 0, len(raw.FindingIDs)) + for _, id := range raw.FindingIDs { + ids = append(ids, finding.ID(id)) + } + insights = append(insights, TriageInsight{ + Kind: InsightKind(raw.Kind), + FindingIDs: ids, + Summary: strings.TrimSpace(raw.Summary), + Priority: priority, + Confidence: raw.Confidence, + CVEs: raw.CVEs, + EvidenceRefs: raw.EvidenceRefs, + Uncertainty: raw.Uncertainty, + Source: SourceLLM, + }) + } + return insights, nil +} diff --git a/internal/triage/bundle.go b/internal/triage/bundle.go new file mode 100644 index 0000000..16307bd --- /dev/null +++ b/internal/triage/bundle.go @@ -0,0 +1,88 @@ +package triage + +import ( + "encoding/json" + + "github.com/MikeRoss27/scanforge/internal/finding" +) + +// TriageFinding is the deliberately reduced projection of a Finding that is +// sent to the LLM. Raw scanner output is never forwarded: evidence and +// descriptions are truncated, and fields that could carry secrets or +// target-controlled content (full response bodies, payloads) are excluded. +// Evidence is intentionally omitted to prevent credential leakage; a static +// placeholder is used instead. +type TriageFinding struct { + ID string `json:"id"` + Asset string `json:"asset"` + URL string `json:"url,omitempty"` + Severity string `json:"severity"` + Source string `json:"source"` + TemplateID string `json:"template_id,omitempty"` + Title string `json:"title"` + Tags []string `json:"tags,omitempty"` + CVEs []string `json:"cves,omitempty"` + CVSS float64 `json:"cvss,omitempty"` + EPSS float64 `json:"epss,omitempty"` + KEV bool `json:"kev,omitempty"` + MatchedAt string `json:"matched_at"` +} + +// TriageBundle is the complete, safe context handed to the analyzer. It +// contains only projections of authoritative data. +type TriageBundle struct { + Target string `json:"target"` + Findings []TriageFinding `json:"findings"` + Relations []finding.FindingRelation `json:"relations"` + // Truncated reports how many findings were left out of the bundle + // because the run exceeded MaxBundleFindings. + Truncated int `json:"truncated,omitempty"` +} + +// BuildBundle projects the authoritative findings into the safe bundle. The +// projection is deterministic and lossy on purpose. +func BuildBundle(target string, findings []finding.Finding, relations []finding.FindingRelation) TriageBundle { + bundle := TriageBundle{Target: target} + + limit := MaxBundleFindings + if len(findings) < limit { + limit = len(findings) + } + included := make(map[finding.ID]struct{}, limit) + for _, f := range findings[:limit] { + included[f.ID] = struct{}{} + bundle.Findings = append(bundle.Findings, TriageFinding{ + ID: string(f.ID), + Asset: f.Asset, + URL: f.URL, + Severity: string(f.Severity), + Source: f.Source, + TemplateID: f.TemplateID, + Title: f.Title, + Tags: f.Tags, + CVEs: f.CVEs, + CVSS: f.CVSS, + EPSS: f.EPSS, + KEV: f.KEV, + MatchedAt: f.MatchedAt, + }) + } + + // Filter relations to only include those where both endpoints are in the bundle. + for _, rel := range relations { + if _, ok1 := included[rel.From]; ok1 { + if _, ok2 := included[rel.To]; ok2 { + bundle.Relations = append(bundle.Relations, rel) + } + } + } + + bundle.Truncated = len(findings) - limit + return bundle +} + +// MarshalJSON renders the bundle for the prompt. +func (b TriageBundle) MarshalJSON() ([]byte, error) { + type alias TriageBundle + return json.Marshal(alias(b)) +} diff --git a/internal/triage/engine.go b/internal/triage/engine.go new file mode 100644 index 0000000..846bf86 --- /dev/null +++ b/internal/triage/engine.go @@ -0,0 +1,425 @@ +package triage + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "time" + + "github.com/MikeRoss27/scanforge/internal/finding" +) + +// Engine runs the triage pipeline: select → group → bundle → analyze → +// validate → reconcile. Deterministic steps always run; the LLM analyzer only +// runs when a model is configured. +type Engine struct { + // Analyzer is the LLM-backed analyzer. Replaced in tests with a fake. + Analyzer Analyzer +} + +// NewEngine returns an engine whose analyzer is the default LLM analyzer. +func NewEngine() *Engine { + return &Engine{} +} + +// Run executes the triage pipeline for one run and writes the outputs +// (manifest.json, insights.json, relations.json, report.md) into OutDir. +func (e *Engine) Run(ctx context.Context, in Input) (*Result, error) { + if in.OutDir == "" { + return nil, fmt.Errorf("triage: OutDir is required") + } + if err := os.MkdirAll(in.OutDir, 0755); err != nil { + return nil, fmt.Errorf("triage: create output dir: %w", err) + } + + digest := inputDigest(in.Target, in.Findings, in.Relations) + temperature := 0.0 + modelName := "" + if in.Model != nil { + if in.Model.Temperature != nil { + temperature = *in.Model.Temperature + } + modelName = in.Model.Model + } + + // Cache: identical input + model + prompt version → reuse previous run. + if !in.Force { + if cached, ok := loadCache(in.OutDir, digest, modelName, temperature); ok { + cached.Manifest.CacheHit = true + cached.Stats.CacheHit = true + return cached, nil + } + } + + // Deterministic steps: group related findings and derive insights. + groups := groupByRelations(in.Relations, in.Findings) + insights := deterministicInsights(in.Findings, groups, in.Relations) + + stats := Stats{ + Findings: len(in.Findings), + Relations: len(in.Relations), + } + + // Optional LLM step: analyze the safe bundle, then validate the output + // against the authoritative facts. + if in.Model != nil { + analyzer := e.Analyzer + if analyzer == nil { + analyzer = NewLLMAnalyzer(in.Model) + } + bundle := BuildBundle(in.Target, in.Findings, in.Relations) + llmInsights, err := analyzer.Analyze(ctx, bundle) + if err != nil { + stats.LLMError = err.Error() + } else { + valid, rejected := ValidateInsights(llmInsights, in.Findings) + stats.LLMInsights = len(valid) + stats.Rejected = rejected + insights = append(insights, valid...) + } + } + + // Reconcile: order insights by priority, then assign stable IDs. + insights = reconcileInsights(insights) + stats.Insights = len(insights) + + manifest := TriageManifest{ + SchemaVersion: SchemaVersion, + PromptVersion: PromptVersion, + Model: modelName, + Provider: providerName(in.Model), + InputDigest: digest, + CreatedAt: time.Now().Format(time.RFC3339), + Temperature: temperature, + Rejected: stats.Rejected, + LLMError: stats.LLMError, + } + + result := &Result{ + Manifest: manifest, + Insights: insights, + Relations: in.Relations, + Stats: stats, + Dir: in.OutDir, + } + if err := writeOutputs(in.OutDir, result); err != nil { + return nil, err + } + return result, nil +} + +// providerName reports the backend label for the manifest. +func providerName(model *ModelConfig) string { + if model == nil { + return "deterministic" + } + return "openai-compatible" +} + +// inputDigest hashes the authoritative input (target + findings + relations) so the +// cache can detect that nothing changed. +func inputDigest(target string, findings []finding.Finding, relations []finding.FindingRelation) string { + payload, err := json.Marshal(struct { + Target string `json:"target"` + Findings []finding.Finding `json:"findings"` + Relations []finding.FindingRelation `json:"relations"` + }{target, findings, relations}) + if err != nil { + return "" + } + sum := sha256.Sum256(payload) + return hex.EncodeToString(sum[:]) +} + +// groupByRelations partitions findings into connected components of the +// relation graph (union-find). Only groups of two or more are returned. +func groupByRelations(relations []finding.FindingRelation, findings []finding.Finding) [][]finding.ID { + parent := make(map[finding.ID]finding.ID) + var find func(id finding.ID) finding.ID + find = func(id finding.ID) finding.ID { + if parent[id] == "" || parent[id] == id { + return id + } + parent[id] = find(parent[id]) + return parent[id] + } + union := func(a, b finding.ID) { + ra, rb := find(a), find(b) + if ra != rb { + parent[rb] = ra + } + } + for _, rel := range relations { + union(rel.From, rel.To) + } + + byRoot := make(map[finding.ID][]finding.ID) + for _, f := range findings { + root := find(f.ID) + byRoot[root] = append(byRoot[root], f.ID) + } + + var groups [][]finding.ID + for _, ids := range byRoot { + if len(ids) < 2 { + continue + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + groups = append(groups, ids) + } + sort.Slice(groups, func(i, j int) bool { return groups[i][0] < groups[j][0] }) + return groups +} + +// deterministicInsights derives rule-based insights: a global summary and one +// duplicate_group insight per duplicate relation group (RelDuplicate only). +func deterministicInsights(findings []finding.Finding, groups [][]finding.ID, relations []finding.FindingRelation) []TriageInsight { + byID := make(map[finding.ID]finding.Finding, len(findings)) + severityCounts := map[finding.Severity]int{} + for _, f := range findings { + byID[f.ID] = f + severityCounts[f.Severity]++ + } + + insights := []TriageInsight{{ + Kind: InsightSummary, + Summary: fmt.Sprintf("%d findings across %d assets: %d critical, %d high, %d medium, %d low, %d info.", + len(findings), countAssets(findings), + severityCounts[finding.SevCritical], severityCounts[finding.SevHigh], + severityCounts[finding.SevMedium], severityCounts[finding.SevLow], + severityCounts[finding.SevInfo]), + Priority: maxPriority(findings), + Confidence: 1.0, + Source: SourceDeterministic, + }} + + // Build a set of duplicate relations for quick lookup. + dupRelations := make(map[finding.ID]map[finding.ID]struct{}) + for _, rel := range relations { + if rel.Type == finding.RelDuplicate { + if dupRelations[rel.From] == nil { + dupRelations[rel.From] = make(map[finding.ID]struct{}) + } + dupRelations[rel.From][rel.To] = struct{}{} + if dupRelations[rel.To] == nil { + dupRelations[rel.To] = make(map[finding.ID]struct{}) + } + dupRelations[rel.To][rel.From] = struct{}{} + } + } + + // Only create InsightDuplicate for groups where all findings are connected + // by RelDuplicate relations (i.e., they form a clique in the duplicate graph). + for _, ids := range groups { + if isDuplicateGroup(ids, dupRelations) { + priority := finding.PrioNone + for _, id := range ids { + if f, ok := byID[id]; ok { + if p := f.Priority(); priorityRank(p) > priorityRank(priority) { + priority = p + } + } + } + insights = append(insights, TriageInsight{ + Kind: InsightDuplicate, + FindingIDs: ids, + Summary: fmt.Sprintf("%d findings are likely the same issue (deterministic duplicate relation).", len(ids)), + Priority: priority, + Confidence: groupConfidence(ids, relations), + Source: SourceDeterministic, + }) + } + } + return insights +} + +// isDuplicateGroup checks if all findings in the group are pairwise connected +// by RelDuplicate relations. +func isDuplicateGroup(ids []finding.ID, dupRelations map[finding.ID]map[finding.ID]struct{}) bool { + for i := 0; i < len(ids); i++ { + for j := i + 1; j < len(ids); j++ { + if _, ok := dupRelations[ids[i]][ids[j]]; !ok { + return false + } + } + } + return true +} + +// groupConfidence returns the strongest relation confidence inside a group. +func groupConfidence(ids []finding.ID, relations []finding.FindingRelation) float64 { + members := make(map[finding.ID]struct{}, len(ids)) + for _, id := range ids { + members[id] = struct{}{} + } + conf := 0.0 + for _, rel := range relations { + _, fromOK := members[rel.From] + _, toOK := members[rel.To] + if fromOK && toOK && rel.Confidence > conf { + conf = rel.Confidence + } + } + return conf +} + +func countAssets(findings []finding.Finding) int { + seen := make(map[string]struct{}) + for _, f := range findings { + if f.Asset != "" { + seen[f.Asset] = struct{}{} + } + } + return len(seen) +} + +func maxPriority(findings []finding.Finding) finding.Priority { + priority := finding.PrioNone + for _, f := range findings { + if p := f.Priority(); priorityRank(p) > priorityRank(priority) { + priority = p + } + } + return priority +} + +// priorityRank orders priorities for sorting (higher = more urgent). +func priorityRank(p finding.Priority) int { + switch p { + case finding.PrioCritical: + return 5 + case finding.PrioHigh: + return 4 + case finding.PrioMedium: + return 3 + case finding.PrioLow: + return 2 + default: + return 1 + } +} + +// reconcileInsights sorts insights by priority (most urgent first) and +// assigns stable sequential IDs. +func reconcileInsights(insights []TriageInsight) []TriageInsight { + sorted := append([]TriageInsight(nil), insights...) + sort.SliceStable(sorted, func(i, j int) bool { + if priorityRank(sorted[i].Priority) != priorityRank(sorted[j].Priority) { + return priorityRank(sorted[i].Priority) > priorityRank(sorted[j].Priority) + } + if sorted[i].Source != sorted[j].Source { + return sorted[i].Source == SourceDeterministic + } + return sorted[i].Summary < sorted[j].Summary + }) + for i := range sorted { + sorted[i].ID = finding.ID(fmt.Sprintf("I-%d", i+1)) + } + return sorted +} + +// writeOutputs persists the triage result into OutDir atomically by writing +// to staging files first, then renaming. This ensures the cache marker +// (FileManifest) only appears after all artifacts are successfully written. +func writeOutputs(dir string, result *Result) error { + stagingDir := filepath.Join(dir, ".staging") + if err := os.MkdirAll(stagingDir, 0755); err != nil { + return fmt.Errorf("triage: create staging dir: %w", err) + } + defer func() { _ = os.RemoveAll(stagingDir) }() + + writeJSON := func(name string, value any) error { + data, err := json.MarshalIndent(value, "", " ") + if err != nil { + return err + } + stagingPath := filepath.Join(stagingDir, name) + if err := os.WriteFile(stagingPath, data, 0644); err != nil { + return err + } + return nil + } + + if err := writeJSON(FileManifest, result.Manifest); err != nil { + return fmt.Errorf("triage: write manifest: %w", err) + } + if err := writeJSON(FileInsights, result.Insights); err != nil { + return fmt.Errorf("triage: write insights: %w", err) + } + if err := writeJSON(FileRelations, result.Relations); err != nil { + return fmt.Errorf("triage: write relations: %w", err) + } + reportPath := filepath.Join(stagingDir, FileReportMD) + if err := os.WriteFile(reportPath, []byte(RenderMarkdown(result)), 0644); err != nil { + return fmt.Errorf("triage: write report: %w", err) + } + + // Atomically move staging files to final location. + files := []string{FileManifest, FileInsights, FileRelations, FileReportMD} + for _, name := range files { + stagingPath := filepath.Join(stagingDir, name) + finalPath := filepath.Join(dir, name) + if err := os.Rename(stagingPath, finalPath); err != nil { + return fmt.Errorf("triage: publish %s: %w", name, err) + } + } + return nil +} + +// loadCache returns the previously computed triage when the input digest, +// model and prompt version all match. It rejects entries missing FileReportMD. +func loadCache(dir, digest, model string, temperature float64) (*Result, bool) { + // Check that all required files exist, including the report. + for _, name := range []string{FileManifest, FileInsights, FileRelations, FileReportMD} { + if _, err := os.Stat(filepath.Join(dir, name)); err != nil { + return nil, false + } + } + + data, err := os.ReadFile(filepath.Join(dir, FileManifest)) + if err != nil { + return nil, false + } + var manifest TriageManifest + if err := json.Unmarshal(data, &manifest); err != nil { + return nil, false + } + if manifest.InputDigest != digest || manifest.Model != model || + manifest.PromptVersion != PromptVersion || manifest.Temperature != temperature { + return nil, false + } + + insightsData, err := os.ReadFile(filepath.Join(dir, FileInsights)) + if err != nil { + return nil, false + } + var insights []TriageInsight + if err := json.Unmarshal(insightsData, &insights); err != nil { + return nil, false + } + relationsData, err := os.ReadFile(filepath.Join(dir, FileRelations)) + if err != nil { + return nil, false + } + var relations []finding.FindingRelation + if err := json.Unmarshal(relationsData, &relations); err != nil { + return nil, false + } + + return &Result{ + Manifest: manifest, + Insights: insights, + Relations: relations, + Stats: Stats{ + CacheHit: true, + Insights: len(insights), + Rejected: manifest.Rejected, + LLMError: manifest.LLMError, + }, + }, true +} diff --git a/internal/triage/engine_test.go b/internal/triage/engine_test.go new file mode 100644 index 0000000..9751d77 --- /dev/null +++ b/internal/triage/engine_test.go @@ -0,0 +1,331 @@ +package triage + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/MikeRoss27/scanforge/internal/finding" +) + +// fakeAnalyzer returns canned insights, optionally failing. +type fakeAnalyzer struct { + insights []TriageInsight + err error + calls int +} + +func (f *fakeAnalyzer) Analyze(_ context.Context, _ TriageBundle) ([]TriageInsight, error) { + f.calls++ + return f.insights, f.err +} + +func mkFinding(source, template, asset, url string, cves []string) finding.Finding { + f := finding.Finding{ + Kind: "vulnerability", Asset: asset, Source: source, + TemplateID: template, URL: url, CVEs: cves, + Severity: finding.SevHigh, MatchedAt: url, + } + f.ID = f.Fingerprint() + return f +} + +func TestEngineDeterministicOnly(t *testing.T) { + dir := t.TempDir() + findings := []finding.Finding{ + mkFinding("nuclei", "t-1", "a.com", "https://a.com/x", nil), + mkFinding("nuclei", "t-1", "a.com", "https://a.com/y", nil), // duplicate + mkFinding("nuclei", "t-2", "b.com", "https://b.com/z", nil), + } + relations := finding.BuildRelations(findings) + + engine := NewEngine() + result, err := engine.Run(context.Background(), Input{ + Target: "example.com", + Findings: findings, + Relations: relations, + OutDir: dir, + }) + if err != nil { + t.Fatalf("Run error: %v", err) + } + + if result.Manifest.Model != "" || result.Manifest.Provider != "deterministic" { + t.Errorf("unexpected manifest: %+v", result.Manifest) + } + if len(result.Insights) != 2 { + t.Fatalf("expected 2 deterministic insights (summary + duplicate), got %d", len(result.Insights)) + } + var summary, dup *TriageInsight + for i := range result.Insights { + switch result.Insights[i].Kind { + case InsightSummary: + summary = &result.Insights[i] + case InsightDuplicate: + dup = &result.Insights[i] + } + } + if summary == nil { + t.Fatal("summary insight missing") + } + if dup == nil { + t.Fatal("duplicate insight missing") + } + if len(dup.FindingIDs) != 2 { + t.Errorf("unexpected duplicate insight: %+v", dup) + } + if dup.Confidence != 1.0 { + t.Errorf("duplicate group confidence should be 1.0, got %v", dup.Confidence) + } + if dup.Source != SourceDeterministic { + t.Errorf("duplicate insight source should be deterministic, got %s", dup.Source) + } + + // Outputs written. + for _, name := range []string{FileManifest, FileInsights, FileRelations, FileReportMD} { + if _, err := os.Stat(filepath.Join(dir, name)); err != nil { + t.Errorf("missing output %s: %v", name, err) + } + } +} + +func TestEngineWithLLMAndValidator(t *testing.T) { + dir := t.TempDir() + findings := []finding.Finding{ + mkFinding("nuclei", "t-1", "a.com", "https://a.com/x", []string{"CVE-2026-0001"}), + } + relations := finding.BuildRelations(findings) + + engine := NewEngine() + engine.Analyzer = &fakeAnalyzer{insights: []TriageInsight{ + { + Kind: InsightPriority, + FindingIDs: []finding.ID{findings[0].ID}, + Summary: "Address this first", + Priority: finding.PrioCritical, + Confidence: 0.9, + CVEs: []string{"CVE-2026-0001"}, + Source: SourceLLM, + }, + { + Kind: InsightPriority, + FindingIDs: []finding.ID{findings[0].ID}, + Summary: "Hallucinated", + Priority: finding.PrioHigh, + Confidence: 0.9, + CVEs: []string{"CVE-2026-99999"}, // unknown → rejected + Source: SourceLLM, + }, + }} + + result, err := engine.Run(context.Background(), Input{ + Target: "example.com", + Findings: findings, + Relations: relations, + Model: &ModelConfig{Model: "qwen3.5-9b", BaseURL: "http://127.0.0.1:8080/v1"}, + OutDir: dir, + }) + if err != nil { + t.Fatalf("Run error: %v", err) + } + + if result.Stats.Rejected != 1 { + t.Errorf("expected 1 rejected insight, got %d", result.Stats.Rejected) + } + if result.Manifest.Rejected != 1 { + t.Errorf("manifest should record 1 rejection, got %d", result.Manifest.Rejected) + } + if result.Manifest.Model != "qwen3.5-9b" { + t.Errorf("manifest model = %q", result.Manifest.Model) + } + + // Valid LLM insight present with stable ID. + var llmInsight *TriageInsight + for i := range result.Insights { + if result.Insights[i].Source == SourceLLM { + llmInsight = &result.Insights[i] + } + } + if llmInsight == nil { + t.Fatal("valid LLM insight missing from result") + } + if llmInsight.ID == "" { + t.Error("insight missing ID") + } +} + +func TestEngineLLMFailureIsNonFatal(t *testing.T) { + dir := t.TempDir() + findings := []finding.Finding{mkFinding("nuclei", "t-1", "a.com", "https://a.com/x", nil)} + + engine := NewEngine() + engine.Analyzer = &fakeAnalyzer{err: context.DeadlineExceeded} + + result, err := engine.Run(context.Background(), Input{ + Target: "example.com", + Findings: findings, + Relations: finding.BuildRelations(findings), + Model: &ModelConfig{Model: "qwen3.5-9b", BaseURL: "http://127.0.0.1:8080/v1"}, + OutDir: dir, + }) + if err != nil { + t.Fatalf("LLM failure must not fail the run: %v", err) + } + if result.Stats.LLMError == "" { + t.Error("expected LLMError recorded in stats") + } + if result.Manifest.LLMError == "" { + t.Error("expected LLMError recorded in manifest") + } + if len(result.Insights) == 0 { + t.Error("deterministic insights must survive an LLM failure") + } +} + +func TestEngineCacheHitAndForce(t *testing.T) { + dir := t.TempDir() + findings := []finding.Finding{mkFinding("nuclei", "t-1", "a.com", "https://a.com/x", nil)} + relations := finding.BuildRelations(findings) + + analyzer := &fakeAnalyzer{insights: []TriageInsight{{ + Kind: InsightObservation, FindingIDs: []finding.ID{findings[0].ID}, + Summary: "observed", Priority: finding.PrioLow, Confidence: 0.5, Source: SourceLLM, + }}} + + engine := NewEngine() + engine.Analyzer = analyzer + in := Input{ + Target: "example.com", + Findings: findings, + Relations: relations, + Model: &ModelConfig{Model: "m", BaseURL: "http://127.0.0.1:8080/v1"}, + OutDir: dir, + } + + if _, err := engine.Run(context.Background(), in); err != nil { + t.Fatalf("first run: %v", err) + } + if analyzer.calls != 1 { + t.Fatalf("expected 1 analyzer call, got %d", analyzer.calls) + } + + // Second run with identical input → cache hit, no analyzer call. + second, err := engine.Run(context.Background(), in) + if err != nil { + t.Fatalf("second run: %v", err) + } + if !second.Stats.CacheHit { + t.Error("expected cache hit on identical input") + } + if analyzer.calls != 1 { + t.Errorf("analyzer must not be called on cache hit, got %d calls", analyzer.calls) + } + + // --force bypasses the cache. + forced, err := engine.Run(context.Background(), Input{ + Target: in.Target, Findings: in.Findings, Relations: in.Relations, + Model: in.Model, Force: true, OutDir: dir, + }) + if err != nil { + t.Fatalf("forced run: %v", err) + } + if forced.Stats.CacheHit { + t.Error("forced run must not report a cache hit") + } + if analyzer.calls != 2 { + t.Errorf("expected analyzer to run again on --force, got %d calls", analyzer.calls) + } +} + +func TestEngineCacheInvalidatedByInputChange(t *testing.T) { + dir := t.TempDir() + analyzer := &fakeAnalyzer{} + + engine := NewEngine() + engine.Analyzer = analyzer + base := Input{ + Target: "example.com", + Model: &ModelConfig{Model: "m", BaseURL: "http://127.0.0.1:8080/v1"}, + OutDir: dir, + } + + base.Findings = []finding.Finding{mkFinding("nuclei", "t-1", "a.com", "https://a.com/x", nil)} + base.Relations = finding.BuildRelations(base.Findings) + if _, err := engine.Run(context.Background(), base); err != nil { + t.Fatalf("first run: %v", err) + } + + // New finding appears → different digest → cache miss. + base.Findings = append(base.Findings, mkFinding("nuclei", "t-2", "b.com", "https://b.com/y", nil)) + base.Relations = finding.BuildRelations(base.Findings) + if _, err := engine.Run(context.Background(), base); err != nil { + t.Fatalf("second run: %v", err) + } + if analyzer.calls != 2 { + t.Errorf("input change must invalidate the cache, got %d analyzer calls", analyzer.calls) + } +} + +func TestBundleTruncatesEvidence(t *testing.T) { + long := make([]byte, 2000) + for i := range long { + long[i] = 'a' + } + f := mkFinding("nuclei", "t-1", "a.com", "https://a.com/x", nil) + f.Evidence = string(long) + + bundle := BuildBundle("example.com", []finding.Finding{f}, nil) + if len(bundle.Findings) != 1 { + t.Fatalf("expected 1 bundled finding") + } + // Evidence field removed from TriageFinding to prevent credential leakage + if bundle.Findings[0].ID == "" { + t.Errorf("finding ID should be set") + } +} + +func TestBundleCapsFindings(t *testing.T) { + var findings []finding.Finding + for i := 0; i < MaxBundleFindings+10; i++ { + findings = append(findings, mkFinding("nuclei", "t", "a.com", "https://a.com/x", nil)) + } + bundle := BuildBundle("example.com", findings, nil) + if len(bundle.Findings) != MaxBundleFindings { + t.Errorf("expected %d bundled findings, got %d", MaxBundleFindings, len(bundle.Findings)) + } + if bundle.Truncated != 10 { + t.Errorf("expected 10 truncated, got %d", bundle.Truncated) + } +} + +func TestManifestJSONRoundTrip(t *testing.T) { + dir := t.TempDir() + findings := []finding.Finding{mkFinding("nuclei", "t-1", "a.com", "https://a.com/x", nil)} + engine := NewEngine() + result, err := engine.Run(context.Background(), Input{ + Target: "example.com", + Findings: findings, + Relations: finding.BuildRelations(findings), + OutDir: dir, + }) + if err != nil { + t.Fatalf("Run error: %v", err) + } + + data, err := os.ReadFile(filepath.Join(dir, FileManifest)) + if err != nil { + t.Fatal(err) + } + var manifest TriageManifest + if err := json.Unmarshal(data, &manifest); err != nil { + t.Fatalf("manifest.json not valid JSON: %v", err) + } + if manifest.SchemaVersion != SchemaVersion || manifest.PromptVersion != PromptVersion { + t.Errorf("unexpected manifest versions: %+v", manifest) + } + if manifest.InputDigest != result.Manifest.InputDigest { + t.Errorf("digest mismatch: %q vs %q", manifest.InputDigest, result.Manifest.InputDigest) + } +} diff --git a/internal/triage/model.go b/internal/triage/model.go new file mode 100644 index 0000000..181cff4 --- /dev/null +++ b/internal/triage/model.go @@ -0,0 +1,130 @@ +// Package triage derives interpretation from ScanForge's authoritative +// findings: deterministic deduplication and grouping, plus optional LLM +// analysis whose output is validated against the facts before being stored. +// Findings are facts owned by ScanForge; insights are derived, auditable and +// never authoritative. +package triage + +import ( + "time" + + "github.com/MikeRoss27/scanforge/internal/finding" +) + +// SchemaVersion of the triage output files (manifest.json, insights.json). +const SchemaVersion = 1 + +// PromptVersion identifies the LLM prompt and output schema. Bumping it +// invalidates cached insights. +const PromptVersion = "triage-v1" + +// MaxBundleFindings caps how many findings are sent to the LLM. Deterministic +// insights always cover every finding; the bundle only feeds the LLM. +const MaxBundleFindings = 150 + +// InsightKind classifies a triage insight. +type InsightKind string + +const ( + // InsightSummary is a global assessment of the findings. + InsightSummary InsightKind = "summary" + // InsightDuplicate flags a group of findings that are likely the same issue. + InsightDuplicate InsightKind = "duplicate_group" + // InsightPriority says which findings to address first and why. + InsightPriority InsightKind = "priority" + // InsightExploitability says whether a finding appears exploitable. + InsightExploitability InsightKind = "exploitability" + // InsightObservation is any other useful interpretation. + InsightObservation InsightKind = "observation" +) + +// InsightSource says where an insight came from. Deterministic insights are +// computed from rules; LLM insights are model interpretations. +type InsightSource string + +const ( + SourceDeterministic InsightSource = "deterministic" + SourceLLM InsightSource = "llm" +) + +// TriageInsight is a derived interpretation of one or more findings. It never +// creates facts: every referenced finding ID, CVE or evidence string must +// exist in the authoritative findings. +type TriageInsight struct { + ID finding.ID `json:"id"` + Kind InsightKind `json:"kind"` + FindingIDs []finding.ID `json:"finding_ids"` + Summary string `json:"summary"` + Priority finding.Priority `json:"priority"` + Confidence float64 `json:"confidence"` + CVEs []string `json:"cves,omitempty"` + EvidenceRefs []string `json:"evidence_refs,omitempty"` + Uncertainty []string `json:"uncertainty,omitempty"` + Source InsightSource `json:"source"` +} + +// TriageManifest records the provenance of a triage run so results are +// reproducible and auditable: which model, which prompt version, which input. +type TriageManifest struct { + SchemaVersion int `json:"schema_version"` + PromptVersion string `json:"prompt_version"` + Model string `json:"model"` + Provider string `json:"provider"` + InputDigest string `json:"input_digest"` + CreatedAt string `json:"created_at"` + Temperature float64 `json:"temperature"` + CacheHit bool `json:"cache_hit,omitempty"` + Rejected int `json:"rejected_insights,omitempty"` + LLMError string `json:"llm_error,omitempty"` +} + +// Stats summarizes one triage run for the CLI output. +type Stats struct { + Findings int + Relations int + Insights int + LLMInsights int + Rejected int + CacheHit bool + LLMError string +} + +// Result is the outcome of one triage run. +type Result struct { + Manifest TriageManifest + Insights []TriageInsight + Relations []finding.FindingRelation + Stats Stats + // Dir is the directory the outputs were written to (OutDir). + Dir string +} + +// ModelConfig describes the LLM backend used by the analyzer. A nil model +// means deterministic-only triage. +type ModelConfig struct { + BaseURL string + Model string + APIKey string + Timeout time.Duration + Temperature *float64 +} + +// Input is everything the engine needs to triage one run. +type Input struct { + Target string + Findings []finding.Finding + Relations []finding.FindingRelation + Model *ModelConfig + Force bool + // OutDir receives triage/manifest.json, insights.json, relations.json and + // report.md. It is also the cache location. + OutDir string +} + +// Output file names inside OutDir. +const ( + FileManifest = "manifest.json" + FileInsights = "insights.json" + FileRelations = "relations.json" + FileReportMD = "report.md" +) diff --git a/internal/triage/render.go b/internal/triage/render.go new file mode 100644 index 0000000..0b33fdf --- /dev/null +++ b/internal/triage/render.go @@ -0,0 +1,66 @@ +package triage + +import ( + "fmt" + "strings" + + "github.com/MikeRoss27/scanforge/internal/finding" +) + +// RenderMarkdown renders the triage result as a human-readable report. +func RenderMarkdown(result *Result) string { + var b strings.Builder + + fmt.Fprintf(&b, "# Triage — %s\n\n", result.Manifest.Model) + fmt.Fprintf(&b, "Input digest: `%s` · prompt `%s` · %s\n\n", + result.Manifest.InputDigest, result.Manifest.PromptVersion, result.Manifest.CreatedAt) + if result.Manifest.CacheHit { + b.WriteString("> Cached result (input unchanged).\n\n") + } + if result.Manifest.LLMError != "" { + fmt.Fprintf(&b, "> LLM analysis failed: %s\n\n", result.Manifest.LLMError) + } + if result.Manifest.Rejected > 0 { + fmt.Fprintf(&b, "> %d LLM insight(s) rejected by validation.\n\n", result.Manifest.Rejected) + } + + fmt.Fprintf(&b, "## Insights (%d)\n\n", len(result.Insights)) + for _, insight := range result.Insights { + source := "deterministic" + if insight.Source == SourceLLM { + source = "llm" + } + fmt.Fprintf(&b, "### %s · %s · %s (%.2f) · %s\n\n", + insight.ID, insight.Kind, insight.Priority, insight.Confidence, source) + b.WriteString(insight.Summary) + b.WriteString("\n\n") + if len(insight.FindingIDs) > 0 { + fmt.Fprintf(&b, "- Findings: %s\n", strings.Join(ids(insight.FindingIDs), ", ")) + } + if len(insight.CVEs) > 0 { + fmt.Fprintf(&b, "- CVEs: %s\n", strings.Join(insight.CVEs, ", ")) + } + if len(insight.EvidenceRefs) > 0 { + fmt.Fprintf(&b, "- Evidence: %s\n", strings.Join(insight.EvidenceRefs, ", ")) + } + if len(insight.Uncertainty) > 0 { + fmt.Fprintf(&b, "- Uncertainty: %s\n", strings.Join(insight.Uncertainty, "; ")) + } + b.WriteString("\n") + } + + fmt.Fprintf(&b, "## Relations (%d)\n\n", len(result.Relations)) + for _, rel := range result.Relations { + fmt.Fprintf(&b, "- `%s` → `%s` · %s · %.2f · %s\n", + rel.From, rel.To, rel.Type, rel.Confidence, rel.Source) + } + return strings.TrimRight(b.String(), "\n") +} + +func ids(values []finding.ID) []string { + out := make([]string, len(values)) + for i, v := range values { + out[i] = string(v) + } + return out +} diff --git a/internal/triage/validator.go b/internal/triage/validator.go new file mode 100644 index 0000000..a3c38ad --- /dev/null +++ b/internal/triage/validator.go @@ -0,0 +1,98 @@ +package triage + +import ( + "strings" + + "github.com/MikeRoss27/scanforge/internal/finding" +) + +// ValidateInsights checks every LLM-produced insight against the +// authoritative facts and drops the ones that reference anything unknown: +// finding IDs, CVEs and evidence strings must all exist in the findings. +// This is the boundary that keeps the model from injecting new "truths". +// It returns the valid insights and the number of rejected ones. +func ValidateInsights(insights []TriageInsight, findings []finding.Finding) (valid []TriageInsight, rejected int) { + known := indexFacts(findings) + for _, insight := range insights { + if validInsight(insight, known) { + valid = append(valid, insight) + } else { + rejected++ + } + } + return valid, rejected +} + +// knownFacts indexes every fact the model is allowed to reference. +type knownFacts struct { + findings map[finding.ID]finding.Finding + cves map[string]struct{} + evidence []string // concatenated evidence/url/matched_at/title per finding +} + +func indexFacts(findings []finding.Finding) knownFacts { + k := knownFacts{ + findings: make(map[finding.ID]finding.Finding, len(findings)), + cves: make(map[string]struct{}), + } + for _, f := range findings { + k.findings[f.ID] = f + for _, cve := range f.CVEs { + k.cves[strings.ToLower(strings.TrimSpace(cve))] = struct{}{} + } + k.evidence = append(k.evidence, + f.Evidence, f.URL, f.MatchedAt, f.Title, f.Asset) + } + return k +} + +func validInsight(insight TriageInsight, known knownFacts) bool { + switch insight.Kind { + case InsightSummary, InsightDuplicate, InsightPriority, InsightExploitability, InsightObservation: + default: + return false + } + if insight.Confidence < 0 || insight.Confidence > 1 { + return false + } + if len(insight.FindingIDs) == 0 { + return false + } + for _, id := range insight.FindingIDs { + if _, ok := known.findings[id]; !ok { + return false + } + } + for _, cve := range insight.CVEs { + if _, ok := known.cves[strings.ToLower(strings.TrimSpace(cve))]; !ok { + return false + } + } + // Validate priority using the canonical ParsePriority behavior. + if insight.Priority != "" { + if _, err := finding.ParsePriority(string(insight.Priority)); err != nil { + return false + } + } + for _, ref := range insight.EvidenceRefs { + if !containsEvidence(known.evidence, ref) { + return false + } + } + return true +} + +// containsEvidence reports whether the reference appears in any finding's +// evidence, URL, matched location, title or asset. +func containsEvidence(haystack []string, ref string) bool { + ref = strings.TrimSpace(ref) + if ref == "" { + return false + } + for _, value := range haystack { + if value != "" && strings.Contains(value, ref) { + return true + } + } + return false +} diff --git a/internal/triage/validator_test.go b/internal/triage/validator_test.go new file mode 100644 index 0000000..f03ed7a --- /dev/null +++ b/internal/triage/validator_test.go @@ -0,0 +1,105 @@ +package triage + +import ( + "testing" + + "github.com/MikeRoss27/scanforge/internal/finding" +) + +func TestValidateInsightsRejectsUnknownFacts(t *testing.T) { + f := mkFinding("nuclei", "t-1", "a.com", "https://a.com/x", []string{"CVE-2026-0001"}) + f.Evidence = "HTTP/1.1 200 OK" + findings := []finding.Finding{f} + + cases := []struct { + name string + insight TriageInsight + rejected bool + }{ + { + name: "valid", + insight: TriageInsight{ + Kind: InsightPriority, FindingIDs: []finding.ID{f.ID}, + Summary: "ok", Priority: finding.PrioHigh, Confidence: 0.9, + CVEs: []string{"CVE-2026-0001"}, EvidenceRefs: []string{"HTTP/1.1 200"}, + Source: SourceLLM, + }, + rejected: false, + }, + { + name: "unknown finding id", + insight: TriageInsight{ + Kind: InsightPriority, FindingIDs: []finding.ID{"F-0000000000000000"}, + Summary: "bad", Priority: finding.PrioHigh, Confidence: 0.9, Source: SourceLLM, + }, + rejected: true, + }, + { + name: "unknown cve", + insight: TriageInsight{ + Kind: InsightPriority, FindingIDs: []finding.ID{f.ID}, + Summary: "bad", Priority: finding.PrioHigh, Confidence: 0.9, + CVEs: []string{"CVE-2026-99999"}, Source: SourceLLM, + }, + rejected: true, + }, + { + name: "unknown evidence ref", + insight: TriageInsight{ + Kind: InsightPriority, FindingIDs: []finding.ID{f.ID}, + Summary: "bad", Priority: finding.PrioHigh, Confidence: 0.9, + EvidenceRefs: []string{"totally-made-up-string"}, Source: SourceLLM, + }, + rejected: true, + }, + { + name: "no finding ids", + insight: TriageInsight{ + Kind: InsightPriority, Summary: "bad", Priority: finding.PrioHigh, + Confidence: 0.9, Source: SourceLLM, + }, + rejected: true, + }, + { + name: "unknown kind", + insight: TriageInsight{ + Kind: "invented_kind", FindingIDs: []finding.ID{f.ID}, + Summary: "bad", Priority: finding.PrioHigh, Confidence: 0.9, Source: SourceLLM, + }, + rejected: true, + }, + { + name: "confidence out of range", + insight: TriageInsight{ + Kind: InsightPriority, FindingIDs: []finding.ID{f.ID}, + Summary: "bad", Priority: finding.PrioHigh, Confidence: 1.5, Source: SourceLLM, + }, + rejected: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + valid, rejected := ValidateInsights([]TriageInsight{tc.insight}, findings) + if tc.rejected && rejected != 1 { + t.Errorf("expected 1 rejection, got %d (valid=%+v)", rejected, valid) + } + if !tc.rejected && rejected != 0 { + t.Errorf("expected 0 rejections, got %d", rejected) + } + }) + } +} + +func TestValidateInsightsEvidenceRefMatchesURL(t *testing.T) { + f := mkFinding("nuclei", "t-1", "a.com", "https://a.com/x", nil) + insight := TriageInsight{ + Kind: InsightObservation, FindingIDs: []finding.ID{f.ID}, + Summary: "ok", Priority: finding.PrioLow, Confidence: 0.5, + EvidenceRefs: []string{"https://a.com/x"}, Source: SourceLLM, + } + valid, rejected := ValidateInsights([]TriageInsight{insight}, []finding.Finding{f}) + if rejected != 0 || len(valid) != 1 { + t.Errorf("URL evidence ref should be valid: rejected=%d valid=%d", rejected, len(valid)) + } +}