configsentry 0.0.25 → 0.0.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,7 +15,7 @@ npx configsentry ./docker-compose.yml
15
15
  ### GitHub Action (minimal)
16
16
 
17
17
  ```yml
18
- - uses: alfredMorgenstern/configsentry@v0.0.24
18
+ - uses: alfredMorgenstern/configsentry@v0.0.25
19
19
  with:
20
20
  target: .
21
21
  ```
@@ -27,7 +27,7 @@ permissions:
27
27
  contents: read
28
28
  security-events: write
29
29
 
30
- - uses: alfredMorgenstern/configsentry@v0.0.24
30
+ - uses: alfredMorgenstern/configsentry@v0.0.25
31
31
  with:
32
32
  target: .
33
33
  sarif: true
@@ -72,10 +72,16 @@ node dist/cli.js --target ./docker-compose.yml
72
72
  node dist/cli.js --target ./docker-compose.yml --format json
73
73
  ```
74
74
 
75
+ Write JSON to a file (no shell redirection needed):
76
+
77
+ ```bash
78
+ node dist/cli.js --target ./docker-compose.yml --format json --output configsentry.json
79
+ ```
80
+
75
81
  ### SARIF output (GitHub Code Scanning)
76
82
 
77
83
  ```bash
78
- node dist/cli.js --target ./docker-compose.yml --format sarif > configsentry.sarif.json
84
+ node dist/cli.js --target ./docker-compose.yml --format sarif --output configsentry.sarif.json
79
85
  ```
80
86
 
81
87
  ## Baselines (incremental adoption)
@@ -146,7 +152,7 @@ jobs:
146
152
  runs-on: ubuntu-latest
147
153
  steps:
148
154
  - uses: actions/checkout@v4
149
- - uses: alfredMorgenstern/configsentry@v0.0.24
155
+ - uses: alfredMorgenstern/configsentry@v0.0.25
150
156
  with:
151
157
  target: .
152
158
  # optional: baseline: .configsentry-baseline.json
@@ -160,13 +166,23 @@ jobs:
160
166
 
161
167
  **Note (consumer repos):** your repo does **not** need a `package-lock.json`. The action installs/builds ConfigSentry from the action package itself.
162
168
 
163
- > Tip: pin to a tag (like `v0.0.18`) for reproducible builds.
169
+ > Tip: pin to a tag (like `v0.0.25`) for reproducible builds.
164
170
 
165
171
  ## Exit codes
166
172
  - `0` no findings
167
173
  - `2` findings present
168
174
  - `1` error
169
175
 
176
+ ## CI: fail only on high severity (optional)
177
+
178
+ If you want ConfigSentry to block builds only on high severity findings:
179
+
180
+ ```bash
181
+ npx configsentry ./docker-compose.yml --severity-threshold high
182
+ ```
183
+
184
+ This also works in GitHub Actions via `args:` (see `docs/action-usage.md`).
185
+
170
186
  ## Example
171
187
 
172
188
  ```bash
package/dist/cli.js CHANGED
@@ -8,6 +8,9 @@ import { runRules } from './rules.js';
8
8
  import { findingsToSarif } from './sarif.js';
9
9
  import { resolveTargets } from './scan.js';
10
10
  import { applyBaseline, loadBaseline, writeBaseline } from './baseline.js';
11
+ function severityRank(s) {
12
+ return s === 'low' ? 1 : s === 'medium' ? 2 : 3;
13
+ }
11
14
  function parseArgs(argv) {
12
15
  const args = argv.slice(2);
13
16
  const help = args.includes('-h') || args.includes('--help');
@@ -29,6 +32,8 @@ function parseArgs(argv) {
29
32
  const baselinePath = baselineIdx >= 0 ? args[baselineIdx + 1] : undefined;
30
33
  const writeBaselineIdx = args.indexOf('--write-baseline');
31
34
  const writeBaselinePath = writeBaselineIdx >= 0 ? args[writeBaselineIdx + 1] : undefined;
35
+ const severityThresholdIdx = args.indexOf('--severity-threshold');
36
+ const severityThreshold = severityThresholdIdx >= 0 ? args[severityThresholdIdx + 1] : undefined;
32
37
  const outputIdx = args.indexOf('--output');
33
38
  const outputPath = outputIdx >= 0 ? args[outputIdx + 1] : undefined;
34
39
  // Prefer explicit flag (matches the GitHub Action input)
@@ -37,7 +42,7 @@ function parseArgs(argv) {
37
42
  // Back-compat: first positional arg
38
43
  const targetFromPositional = args.find((a) => !a.startsWith('-'));
39
44
  const target = targetFromFlag ?? targetFromPositional;
40
- return { args, help, version, output, format, outputPath, baselinePath, writeBaselinePath, target };
45
+ return { args, help, version, output, format, outputPath, baselinePath, writeBaselinePath, severityThreshold, target };
41
46
  }
42
47
  function usage() {
43
48
  console.log(`ConfigSentry (MVP)
@@ -51,6 +56,8 @@ Output:
51
56
  --sarif SARIF 2.1.0 (for GitHub code scanning) (deprecated; use --format sarif)
52
57
  --format <pretty|json|sarif>
53
58
  --output <file> write JSON/SARIF output to a file (use with --format)
59
+ --severity-threshold <low|medium|high>
60
+ only report findings at/above this severity (affects exit code)
54
61
 
55
62
  Baselines:
56
63
  --baseline <file> suppress findings present in a baseline file
@@ -63,7 +70,7 @@ Exit codes:
63
70
  `);
64
71
  }
65
72
  async function main() {
66
- const { args, help, version, output, format, outputPath, baselinePath, writeBaselinePath, target } = parseArgs(process.argv);
73
+ const { args, help, version, output, format, outputPath, baselinePath, writeBaselinePath, severityThreshold, target } = parseArgs(process.argv);
67
74
  if (version) {
68
75
  try {
69
76
  const here = path.dirname(fileURLToPath(import.meta.url));
@@ -97,6 +104,10 @@ async function main() {
97
104
  console.error('Error: --output requires machine output (use --format json or --format sarif)');
98
105
  process.exit(1);
99
106
  }
107
+ if (severityThreshold && severityThreshold !== 'low' && severityThreshold !== 'medium' && severityThreshold !== 'high') {
108
+ console.error(`Error: invalid --severity-threshold '${severityThreshold}'. Expected: low | medium | high`);
109
+ process.exit(1);
110
+ }
100
111
  if (!target) {
101
112
  usage();
102
113
  process.exit(1);
@@ -120,6 +131,14 @@ async function main() {
120
131
  findings = res.kept;
121
132
  suppressed = res.suppressed;
122
133
  }
134
+ // Severity threshold filtering (affects reporting + exit code)
135
+ if (severityThreshold) {
136
+ const min = severityRank(severityThreshold);
137
+ findings = findings.filter((f) => {
138
+ const sev = f.severity ?? 'low';
139
+ return severityRank(sev) >= min;
140
+ });
141
+ }
123
142
  // Baseline generation mode
124
143
  if (writeBaselinePath) {
125
144
  await writeBaseline(path.resolve(writeBaselinePath), allFindings);
package/dist/rules.js CHANGED
@@ -1,4 +1,43 @@
1
1
  const SENSITIVE_PORTS = new Set([5432, 3306, 6379, 27017, 9200]);
2
+ const SECRET_KEY_RE = /(pass(word)?|secret|token|api[_-]?key|private[_-]?key)/i;
3
+ const PLACEHOLDER_VALUE_RE = /^(changeme|change-me|password|secret|token|example|example_password|yourpassword|your_password|replace_me|replace-me|TODO)$/i;
4
+ function extractEnv(svc) {
5
+ const env = svc?.environment;
6
+ const res = [];
7
+ // environment:
8
+ // KEY: value
9
+ // or
10
+ // environment:
11
+ // - KEY=value
12
+ // - KEY
13
+ if (env && typeof env === 'object' && !Array.isArray(env)) {
14
+ for (const [k, v] of Object.entries(env)) {
15
+ if (typeof k !== 'string')
16
+ continue;
17
+ if (v == null)
18
+ continue;
19
+ res.push({ key: k, value: String(v), raw: `${k}=${String(v)}` });
20
+ }
21
+ return res;
22
+ }
23
+ if (Array.isArray(env)) {
24
+ for (const item of env) {
25
+ if (typeof item !== 'string')
26
+ continue;
27
+ const idx = item.indexOf('=');
28
+ if (idx === -1) {
29
+ // KEY (value from environment at runtime) — not a hardcoded secret
30
+ continue;
31
+ }
32
+ const key = item.slice(0, idx);
33
+ const value = item.slice(idx + 1);
34
+ if (!key)
35
+ continue;
36
+ res.push({ key, value, raw: item });
37
+ }
38
+ }
39
+ return res;
40
+ }
2
41
  function normalizePorts(ports) {
3
42
  if (!Array.isArray(ports))
4
43
  return [];
@@ -261,6 +300,27 @@ export function runRules(compose, targetPath) {
261
300
  suggestion: 'Set user: "1000:1000" (or a dedicated UID/GID) and ensure the image supports running unprivileged.'
262
301
  });
263
302
  }
303
+ // Rule: hardcoded secrets in environment
304
+ for (const { key, value, raw } of extractEnv(svc)) {
305
+ if (!SECRET_KEY_RE.test(key))
306
+ continue;
307
+ const v = String(value).trim();
308
+ if (v.length === 0)
309
+ continue;
310
+ // ${VAR} / ${VAR:-default} / ${VAR?err}
311
+ if (v.startsWith('${') && v.endsWith('}'))
312
+ continue;
313
+ const placeholder = PLACEHOLDER_VALUE_RE.test(v) || v.toLowerCase().includes('changeme');
314
+ findings.push({
315
+ id: 'compose.hardcoded-secret',
316
+ title: 'Possible hardcoded secret in compose environment',
317
+ severity: placeholder ? 'medium' : 'high',
318
+ message: `Service '${serviceName}' sets '${key}' to a literal value in environment ('${raw}').`,
319
+ service: serviceName,
320
+ path: `${targetPath}#services.${serviceName}.environment`,
321
+ suggestion: 'Avoid committing secrets in docker-compose.yml. Prefer ${VAR} with a .env file (gitignored) or Docker secrets where supported.'
322
+ });
323
+ }
264
324
  // Rule: filesystem not read-only (hardening)
265
325
  // Low severity because many images expect write access unless explicitly designed for read-only.
266
326
  if (svc?.read_only !== true) {
@@ -274,6 +334,41 @@ export function runRules(compose, targetPath) {
274
334
  suggestion: 'Consider setting read_only: true + add explicit writable mounts (e.g. tmpfs:/tmp or a data volume) if the app supports it.'
275
335
  });
276
336
  }
337
+ // Rule: floating / unpinned image tags
338
+ const image = svc?.image;
339
+ if (typeof image === 'string' && image.trim() !== '') {
340
+ // If pinned by digest, it's reproducible.
341
+ if (!image.includes('@')) {
342
+ const lastSlash = image.lastIndexOf('/');
343
+ const lastColon = image.lastIndexOf(':');
344
+ const hasTag = lastColon > lastSlash;
345
+ if (!hasTag) {
346
+ findings.push({
347
+ id: 'compose.image-floating-tag',
348
+ title: 'Image tag not pinned',
349
+ severity: 'medium',
350
+ message: `Service '${serviceName}' uses an image without an explicit tag ('${image}').`,
351
+ service: serviceName,
352
+ path: `${targetPath}#services.${serviceName}.image`,
353
+ suggestion: "Pin the image to a version tag (e.g. 'nginx:1.27') or a digest (e.g. 'nginx@sha256:...') for reproducible deployments."
354
+ });
355
+ }
356
+ else {
357
+ const tag = image.slice(lastColon + 1).trim();
358
+ if (tag.toLowerCase() === 'latest') {
359
+ findings.push({
360
+ id: 'compose.image-floating-tag',
361
+ title: 'Image tag not pinned',
362
+ severity: 'medium',
363
+ message: `Service '${serviceName}' uses a floating image tag ('${image}').`,
364
+ service: serviceName,
365
+ path: `${targetPath}#services.${serviceName}.image`,
366
+ suggestion: "Pin the image to a version tag (e.g. 'nginx:1.27') or a digest (e.g. 'nginx@sha256:...') for reproducible deployments."
367
+ });
368
+ }
369
+ }
370
+ }
371
+ }
277
372
  // Rule: exposed sensitive ports
278
373
  const ports = normalizePorts(svc?.ports);
279
374
  for (const p of ports) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "configsentry",
3
- "version": "0.0.25",
3
+ "version": "0.0.27",
4
4
  "description": "Developer-first guardrails for docker-compose.yml (security + ops footguns).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -31,7 +31,9 @@
31
31
  "prepack": "npm run build",
32
32
  "start": "node dist/cli.js",
33
33
  "dev": "node --loader ts-node/esm src/cli.ts",
34
- "lint:example": "node dist/cli.js ./example.docker-compose.yml"
34
+ "lint:example": "node dist/cli.js ./example.docker-compose.yml",
35
+ "demo": "node dist/cli.js --target ./docs/demo/docker-compose.demo.yml",
36
+ "demo:json": "node dist/cli.js --target ./docs/demo/docker-compose.demo.yml --format json --output /tmp/configsentry-demo.json"
35
37
  },
36
38
  "keywords": [
37
39
  "docker",