@scalequality/check 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/bin/check.mjs +83 -1
  2. package/package.json +1 -1
package/bin/check.mjs CHANGED
@@ -26,6 +26,9 @@ Options:
26
26
  --token <t> SQ token (or env SQ_TOKEN)
27
27
  --project <id> ScaleQuality project id (or env SQ_PROJECT)
28
28
  --host <url> API host (default https://app.scalequality.io, or env SQ_HOST)
29
+ --image <ref> Also scan the container image this job just built, e.g.
30
+ myapp:\${{ github.sha }}. Needs trivy on PATH; skipped with a
31
+ note if it is missing, never fails the build for it.
29
32
  --timeout <s> Max seconds to wait for the measurement (default 600)
30
33
  --json Print the raw result as JSON
31
34
  -h, --help Show this help
@@ -38,6 +41,7 @@ const token = flag("--token") || process.env.SQ_TOKEN
38
41
  const project = flag("--project") || process.env.SQ_PROJECT
39
42
  const host = (flag("--host") || process.env.SQ_HOST || "https://app.scalequality.io").replace(/\/+$/, "")
40
43
  const jsonOut = has("--json")
44
+ const image = flag("--image") || process.env.SQ_IMAGE
41
45
  const timeoutMs = Math.max(30, Number(flag("--timeout") || 600)) * 1000
42
46
 
43
47
  const base = `${host}/api/insights/continuous`
@@ -49,6 +53,76 @@ if (!token || !project) {
49
53
  fail("--token and --project are required (or env SQ_TOKEN / SQ_PROJECT). See --help.")
50
54
  }
51
55
 
56
+ /**
57
+ * Scans the image THIS job just built. Container CVEs cannot be measured from a
58
+ * repository: they live in the built image, and the only place that image exists
59
+ * is right here. So the scan runs in the customer's own pipeline and only the
60
+ * report travels.
61
+ *
62
+ * Two rules, both about not lying:
63
+ * - no `--image`, no scan, and nothing is reported as clean;
64
+ * - `--image` without trivy prints how to add it and CONTINUES. The container
65
+ * dimension comes back not-measured, but the maturity gate still runs. A
66
+ * missing scanner is not a reason to break someone's build.
67
+ */
68
+ function scanImage() {
69
+ if (!image) return null
70
+ const probe = spawnSync("trivy", ["--version"], { encoding: "utf8" })
71
+ if (probe.error || probe.status !== 0) {
72
+ log("ScaleQuality: --image was given but trivy is not on PATH, so the container scan was skipped.")
73
+ log("ScaleQuality: add it before this step, e.g. `- uses: aquasecurity/setup-trivy@v0.2.0`.")
74
+ return null
75
+ }
76
+ const r = spawnSync("trivy", [
77
+ "image", "--format", "json", "--quiet",
78
+ "--scanners", "vuln",
79
+ // LOW is dropped for the same reason it is dropped everywhere else here: a
80
+ // noisy finding costs more credibility than a missed one.
81
+ "--severity", "CRITICAL,HIGH,MEDIUM",
82
+ image,
83
+ ], { encoding: "utf8", maxBuffer: 128 * 1024 * 1024 })
84
+ if (r.error || r.status !== 0 || !r.stdout) {
85
+ log(`ScaleQuality: could not scan ${image}, continuing without the container scan.`)
86
+ return null
87
+ }
88
+ return slimReport(r.stdout, image)
89
+ }
90
+
91
+ /**
92
+ * trivy ships the full advisory text and reference list for every finding: 933KB
93
+ * for a single base image, of which we read a handful of fields. Sending the rest
94
+ * costs the customer's pipeline upload time for nothing. Same shape, fewer bytes,
95
+ * so the server parser stays untouched.
96
+ */
97
+ function slimReport(stdout, ref) {
98
+ let d
99
+ try { d = JSON.parse(stdout) } catch { return null }
100
+ let n = 0
101
+ const out = {
102
+ ArtifactName: d.ArtifactName || ref,
103
+ Metadata: { OS: d.Metadata?.OS ? { Family: d.Metadata.OS.Family, Name: d.Metadata.OS.Name, EOSL: d.Metadata.OS.EOSL } : undefined },
104
+ Results: (d.Results || []).map((res) => ({
105
+ Target: res.Target,
106
+ Class: res.Class,
107
+ Type: res.Type,
108
+ Vulnerabilities: (res.Vulnerabilities || []).map((v) => {
109
+ n++
110
+ return {
111
+ VulnerabilityID: v.VulnerabilityID,
112
+ PkgName: v.PkgName,
113
+ InstalledVersion: v.InstalledVersion,
114
+ FixedVersion: v.FixedVersion,
115
+ Severity: v.Severity,
116
+ Title: typeof v.Title === "string" ? v.Title.slice(0, 300) : undefined,
117
+ }
118
+ }),
119
+ })),
120
+ }
121
+ const json = JSON.stringify(out)
122
+ log(`ScaleQuality: scanned ${out.ArtifactName} — ${n} vulnerabilities`)
123
+ return json
124
+ }
125
+
52
126
  async function main() {
53
127
  const auth = { Authorization: `Bearer ${token}` }
54
128
 
@@ -78,6 +152,8 @@ async function main() {
78
152
  const form = new FormData()
79
153
  form.append("project", project)
80
154
  form.append("file", new Blob([buf]), "checkout.tar.gz")
155
+ const containerReport = scanImage()
156
+ if (containerReport) form.append("containerReport", containerReport)
81
157
  const r = await fetch(`${base}/report/upload`, {
82
158
  method: "POST",
83
159
  headers: auth,
@@ -94,10 +170,16 @@ async function main() {
94
170
  if (!runId) fail("no run id returned.")
95
171
  log(`ScaleQuality: measuring ${project} …`)
96
172
 
173
+ // The poll interval is part of the measured gate time, not just politeness. A flat
174
+ // 5s quantizes every verdict to a 5s boundary: an analysis that finishes in 7s is
175
+ // only reported at 10s, and the pipeline pays 3s of pure waiting. Start tight and
176
+ // back off, so a fast repo answers fast and a slow one still polls cheaply.
177
+ const POLL_MS = [1500, 1500, 2000, 2000, 3000]
97
178
  const deadline = Date.now() + timeoutMs
98
179
  let last
180
+ let poll = 0
99
181
  while (Date.now() < deadline) {
100
- await sleep(5000)
182
+ await sleep(POLL_MS[Math.min(poll++, POLL_MS.length - 1)])
101
183
  const sr = await fetch(`${base}/report/${runId}`, { headers: auth }).catch((e) => fail(`status check failed (${e.message})`))
102
184
  if (!sr.ok) { const b = await sr.text().catch(() => ""); fail(`status failed (${sr.status}) ${b}`) }
103
185
  last = await sr.json()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalequality/check",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "ScaleQuality continuous code-maturity check for CI. Re-measures your repo on every build and fails the pipeline below your maturity line.",
5
5
  "type": "module",
6
6
  "bin": {