configsentry 0.0.18 → 0.0.20
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 +50 -8
- package/dist/cli.js +46 -14
- package/dist/rules.js +76 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,6 +4,37 @@
|
|
|
4
4
|
|
|
5
5
|
Developer-first guardrails for **docker-compose.yml** (security + ops footguns).
|
|
6
6
|
|
|
7
|
+
## 60-second quickstart
|
|
8
|
+
|
|
9
|
+
### Local (npx)
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx configsentry ./docker-compose.yml
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
### GitHub Action (minimal)
|
|
16
|
+
|
|
17
|
+
```yml
|
|
18
|
+
- uses: alfredMorgenstern/configsentry@v0.0.18
|
|
19
|
+
with:
|
|
20
|
+
target: .
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### GitHub Code Scanning (SARIF upload)
|
|
24
|
+
|
|
25
|
+
```yml
|
|
26
|
+
permissions:
|
|
27
|
+
contents: read
|
|
28
|
+
security-events: write
|
|
29
|
+
|
|
30
|
+
- uses: alfredMorgenstern/configsentry@v0.0.18
|
|
31
|
+
with:
|
|
32
|
+
target: .
|
|
33
|
+
sarif: true
|
|
34
|
+
upload-sarif: true
|
|
35
|
+
fail-on-findings: false
|
|
36
|
+
```
|
|
37
|
+
|
|
7
38
|
## What it does
|
|
8
39
|
ConfigSentry reads a Compose file and flags common **high-impact** mistakes:
|
|
9
40
|
- privileged containers (`privileged: true`)
|
|
@@ -11,6 +42,7 @@ ConfigSentry reads a Compose file and flags common **high-impact** mistakes:
|
|
|
11
42
|
- host namespaces (`network_mode: host`, `pid: host`, `ipc: host`)
|
|
12
43
|
- unconfined security profiles (`security_opt: ["seccomp=unconfined"]` / `apparmor:unconfined`)
|
|
13
44
|
- Docker socket mounts (`/var/run/docker.sock`)
|
|
45
|
+
- sensitive host mounts (`/etc`, `/proc`, `/sys`)
|
|
14
46
|
- sensitive ports exposed publicly (e.g. `5432:5432` instead of `127.0.0.1:5432:5432`)
|
|
15
47
|
- missing `restart:` policy
|
|
16
48
|
- missing `healthcheck:`
|
|
@@ -31,19 +63,19 @@ npx configsentry ./docker-compose.yml
|
|
|
31
63
|
```bash
|
|
32
64
|
npm install
|
|
33
65
|
npm run build
|
|
34
|
-
node dist/cli.js ./docker-compose.yml
|
|
66
|
+
node dist/cli.js --target ./docker-compose.yml
|
|
35
67
|
```
|
|
36
68
|
|
|
37
69
|
### JSON output (CI / tooling)
|
|
38
70
|
|
|
39
71
|
```bash
|
|
40
|
-
node dist/cli.js ./docker-compose.yml --json
|
|
72
|
+
node dist/cli.js --target ./docker-compose.yml --json
|
|
41
73
|
```
|
|
42
74
|
|
|
43
75
|
### SARIF output (GitHub Code Scanning)
|
|
44
76
|
|
|
45
77
|
```bash
|
|
46
|
-
node dist/cli.js ./docker-compose.yml --sarif > configsentry.sarif.json
|
|
78
|
+
node dist/cli.js --target ./docker-compose.yml --sarif > configsentry.sarif.json
|
|
47
79
|
```
|
|
48
80
|
|
|
49
81
|
## Baselines (incremental adoption)
|
|
@@ -51,15 +83,23 @@ node dist/cli.js ./docker-compose.yml --sarif > configsentry.sarif.json
|
|
|
51
83
|
Generate a baseline (captures current findings):
|
|
52
84
|
|
|
53
85
|
```bash
|
|
54
|
-
node dist/cli.js ./docker-compose.yml --write-baseline .configsentry-baseline.json
|
|
86
|
+
node dist/cli.js --target ./docker-compose.yml --write-baseline .configsentry-baseline.json
|
|
55
87
|
```
|
|
56
88
|
|
|
57
89
|
Then suppress baseline findings in CI:
|
|
58
90
|
|
|
59
91
|
```bash
|
|
60
|
-
node dist/cli.js ./docker-compose.yml --baseline .configsentry-baseline.json
|
|
92
|
+
node dist/cli.js --target ./docker-compose.yml --baseline .configsentry-baseline.json
|
|
61
93
|
```
|
|
62
94
|
|
|
95
|
+
## Docs
|
|
96
|
+
|
|
97
|
+
- GitHub Action usage examples: [`docs/action-usage.md`](docs/action-usage.md)
|
|
98
|
+
- Baselines (incremental adoption): [`docs/baselines.md`](docs/baselines.md)
|
|
99
|
+
- Compatibility & scope: [`docs/compatibility.md`](docs/compatibility.md)
|
|
100
|
+
- Troubleshooting / FAQ: [`docs/troubleshooting.md`](docs/troubleshooting.md)
|
|
101
|
+
- Launch pack (links + demo assets): [`docs/launch-pack.md`](docs/launch-pack.md)
|
|
102
|
+
|
|
63
103
|
## Use in GitHub Actions (copy/paste)
|
|
64
104
|
|
|
65
105
|
More examples: [`docs/action-usage.md`](docs/action-usage.md)
|
|
@@ -81,7 +121,7 @@ jobs:
|
|
|
81
121
|
|
|
82
122
|
- run: npm ci
|
|
83
123
|
- run: npm run build
|
|
84
|
-
- run: node dist/cli.js ./docker-compose.yml
|
|
124
|
+
- run: node dist/cli.js --target ./docker-compose.yml
|
|
85
125
|
```
|
|
86
126
|
|
|
87
127
|
### Option B: use the ConfigSentry composite action
|
|
@@ -111,7 +151,9 @@ jobs:
|
|
|
111
151
|
# security-events: write
|
|
112
152
|
```
|
|
113
153
|
|
|
114
|
-
|
|
154
|
+
**Note (consumer repos):** your repo does **not** need a `package-lock.json`. The action installs/builds ConfigSentry from the action package itself.
|
|
155
|
+
|
|
156
|
+
> Tip: pin to a tag (like `v0.0.18`) for reproducible builds.
|
|
115
157
|
|
|
116
158
|
## Exit codes
|
|
117
159
|
- `0` no findings
|
|
@@ -121,7 +163,7 @@ jobs:
|
|
|
121
163
|
## Example
|
|
122
164
|
|
|
123
165
|
```bash
|
|
124
|
-
node dist/cli.js ./example.docker-compose.yml
|
|
166
|
+
node dist/cli.js --target ./example.docker-compose.yml
|
|
125
167
|
```
|
|
126
168
|
|
|
127
169
|
## Next steps
|
package/dist/cli.js
CHANGED
|
@@ -8,15 +8,49 @@ 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 parseArgs(argv) {
|
|
12
|
+
const args = argv.slice(2);
|
|
13
|
+
const help = args.includes('-h') || args.includes('--help');
|
|
14
|
+
const version = args.includes('-v') || args.includes('--version');
|
|
15
|
+
const json = args.includes('--json');
|
|
16
|
+
const sarif = args.includes('--sarif');
|
|
17
|
+
const output = json ? 'json' : sarif ? 'sarif' : 'pretty';
|
|
18
|
+
const baselineIdx = args.indexOf('--baseline');
|
|
19
|
+
const baselinePath = baselineIdx >= 0 ? args[baselineIdx + 1] : undefined;
|
|
20
|
+
const writeBaselineIdx = args.indexOf('--write-baseline');
|
|
21
|
+
const writeBaselinePath = writeBaselineIdx >= 0 ? args[writeBaselineIdx + 1] : undefined;
|
|
22
|
+
// Prefer explicit flag (matches the GitHub Action input)
|
|
23
|
+
const targetIdx = args.indexOf('--target');
|
|
24
|
+
const targetFromFlag = targetIdx >= 0 ? args[targetIdx + 1] : undefined;
|
|
25
|
+
// Back-compat: first positional arg
|
|
26
|
+
const targetFromPositional = args.find((a) => !a.startsWith('-'));
|
|
27
|
+
const target = targetFromFlag ?? targetFromPositional;
|
|
28
|
+
return { args, help, version, output, baselinePath, writeBaselinePath, target };
|
|
29
|
+
}
|
|
11
30
|
function usage() {
|
|
12
|
-
console.log(`ConfigSentry (MVP)
|
|
31
|
+
console.log(`ConfigSentry (MVP)
|
|
32
|
+
|
|
33
|
+
Usage:
|
|
34
|
+
configsentry <file-or-dir> [--json|--sarif] [--baseline <file>] [--write-baseline <file>]
|
|
35
|
+
configsentry --target <file-or-dir> [--json|--sarif] [--baseline <file>] [--write-baseline <file>]
|
|
36
|
+
|
|
37
|
+
Output:
|
|
38
|
+
--json machine-readable findings
|
|
39
|
+
--sarif SARIF 2.1.0 (for GitHub code scanning)
|
|
40
|
+
|
|
41
|
+
Baselines:
|
|
42
|
+
--baseline <file> suppress findings present in a baseline file
|
|
43
|
+
--write-baseline <file> write baseline file for current findings and exit 0
|
|
44
|
+
|
|
45
|
+
Exit codes:
|
|
46
|
+
0 = no findings (after baseline suppression)
|
|
13
47
|
2 = findings present
|
|
14
48
|
1 = error
|
|
15
49
|
`);
|
|
16
50
|
}
|
|
17
51
|
async function main() {
|
|
18
|
-
const args = process.argv
|
|
19
|
-
if (
|
|
52
|
+
const { args, help, version, output, baselinePath, writeBaselinePath, target } = parseArgs(process.argv);
|
|
53
|
+
if (version) {
|
|
20
54
|
try {
|
|
21
55
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
22
56
|
const pkgPath = path.resolve(here, '../package.json');
|
|
@@ -29,21 +63,19 @@ async function main() {
|
|
|
29
63
|
}
|
|
30
64
|
process.exit(0);
|
|
31
65
|
}
|
|
32
|
-
if (args.length === 0 ||
|
|
66
|
+
if (args.length === 0 || help) {
|
|
33
67
|
usage();
|
|
34
68
|
process.exit(0);
|
|
35
69
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
70
|
+
if (output === 'json' && args.includes('--sarif')) {
|
|
71
|
+
// should be impossible due to parseArgs, but keep a clear message
|
|
72
|
+
console.error('Error: choose only one output mode: --json or --sarif');
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
if (output === 'sarif' && args.includes('--json')) {
|
|
39
76
|
console.error('Error: choose only one output mode: --json or --sarif');
|
|
40
77
|
process.exit(1);
|
|
41
78
|
}
|
|
42
|
-
const baselineIdx = args.indexOf('--baseline');
|
|
43
|
-
const baselinePath = baselineIdx >= 0 ? args[baselineIdx + 1] : undefined;
|
|
44
|
-
const writeBaselineIdx = args.indexOf('--write-baseline');
|
|
45
|
-
const writeBaselinePath = writeBaselineIdx >= 0 ? args[writeBaselineIdx + 1] : undefined;
|
|
46
|
-
const target = args.find((a) => !a.startsWith('-'));
|
|
47
79
|
if (!target) {
|
|
48
80
|
usage();
|
|
49
81
|
process.exit(1);
|
|
@@ -73,10 +105,10 @@ async function main() {
|
|
|
73
105
|
console.log(`Wrote baseline: ${path.resolve(writeBaselinePath)} (${allFindings.length} finding(s))`);
|
|
74
106
|
process.exit(0);
|
|
75
107
|
}
|
|
76
|
-
if (json) {
|
|
108
|
+
if (output === 'json') {
|
|
77
109
|
console.log(JSON.stringify({ targetPaths, findings, suppressedCount: suppressed.length }, null, 2));
|
|
78
110
|
}
|
|
79
|
-
else if (sarif) {
|
|
111
|
+
else if (output === 'sarif') {
|
|
80
112
|
console.log(JSON.stringify(findingsToSarif(findings), null, 2));
|
|
81
113
|
}
|
|
82
114
|
else {
|
package/dist/rules.js
CHANGED
|
@@ -113,9 +113,66 @@ export function runRules(compose, targetPath) {
|
|
|
113
113
|
// Rule: docker socket mount
|
|
114
114
|
const volumes = Array.isArray(svc?.volumes) ? svc.volumes : [];
|
|
115
115
|
for (const v of volumes) {
|
|
116
|
-
|
|
116
|
+
// Support both short syntax (string) and long syntax (object).
|
|
117
|
+
// Long syntax example:
|
|
118
|
+
// - type: bind
|
|
119
|
+
// source: /etc
|
|
120
|
+
// target: /host-etc
|
|
121
|
+
let raw = '';
|
|
122
|
+
let hostPath;
|
|
123
|
+
if (typeof v === 'string') {
|
|
124
|
+
raw = v;
|
|
125
|
+
hostPath = v.split(':')[0];
|
|
126
|
+
}
|
|
127
|
+
else if (v && typeof v === 'object') {
|
|
128
|
+
raw = JSON.stringify(v);
|
|
129
|
+
const type = String(v.type ?? '').toLowerCase();
|
|
130
|
+
if (type === '' || type === 'bind') {
|
|
131
|
+
hostPath = v.source ?? v.src;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
117
135
|
continue;
|
|
118
|
-
|
|
136
|
+
}
|
|
137
|
+
// Rule: sensitive host path mounts
|
|
138
|
+
// Only consider bind mounts where the host path is absolute.
|
|
139
|
+
if (typeof hostPath === 'string' && hostPath.startsWith('/')) {
|
|
140
|
+
const hp = hostPath.replace(/\/$/, '');
|
|
141
|
+
if (hp === '/etc' || hp.startsWith('/etc/')) {
|
|
142
|
+
findings.push({
|
|
143
|
+
id: 'compose.host-etc-mount',
|
|
144
|
+
title: 'Sensitive host path mounted (/etc)',
|
|
145
|
+
severity: 'high',
|
|
146
|
+
message: `Service '${serviceName}' mounts host /etc into the container ('${raw}').`,
|
|
147
|
+
service: serviceName,
|
|
148
|
+
path: `${targetPath}#services.${serviceName}.volumes`,
|
|
149
|
+
suggestion: 'Avoid mounting /etc. If you only need a single config file, mount that file explicitly read-only.'
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
if (hp === '/proc' || hp.startsWith('/proc/')) {
|
|
153
|
+
findings.push({
|
|
154
|
+
id: 'compose.host-proc-mount',
|
|
155
|
+
title: 'Sensitive host path mounted (/proc)',
|
|
156
|
+
severity: 'high',
|
|
157
|
+
message: `Service '${serviceName}' mounts host /proc into the container ('${raw}').`,
|
|
158
|
+
service: serviceName,
|
|
159
|
+
path: `${targetPath}#services.${serviceName}.volumes`,
|
|
160
|
+
suggestion: 'Avoid mounting /proc. If you need host metrics, prefer safer exporters or explicit APIs.'
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
if (hp === '/sys' || hp.startsWith('/sys/')) {
|
|
164
|
+
findings.push({
|
|
165
|
+
id: 'compose.host-sys-mount',
|
|
166
|
+
title: 'Sensitive host path mounted (/sys)',
|
|
167
|
+
severity: 'high',
|
|
168
|
+
message: `Service '${serviceName}' mounts host /sys into the container ('${raw}').`,
|
|
169
|
+
service: serviceName,
|
|
170
|
+
path: `${targetPath}#services.${serviceName}.volumes`,
|
|
171
|
+
suggestion: 'Avoid mounting /sys. If hardware/host introspection is required, isolate the container and mount only specific needed subpaths read-only.'
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (raw.includes('/var/run/docker.sock')) {
|
|
119
176
|
findings.push({
|
|
120
177
|
id: 'compose.docker-socket',
|
|
121
178
|
title: 'Docker socket mounted',
|
|
@@ -126,23 +183,23 @@ export function runRules(compose, targetPath) {
|
|
|
126
183
|
suggestion: 'Avoid mounting the docker socket. If you need it, isolate the runner and treat it as privileged infrastructure.'
|
|
127
184
|
});
|
|
128
185
|
}
|
|
129
|
-
if (
|
|
186
|
+
if (raw.startsWith('/:') || raw.startsWith('/:/') || hostPath === '/') {
|
|
130
187
|
findings.push({
|
|
131
188
|
id: 'compose.host-root-mount',
|
|
132
189
|
title: 'Host root mounted',
|
|
133
190
|
severity: 'high',
|
|
134
|
-
message: `Service '${serviceName}' appears to mount the host root filesystem ('${
|
|
191
|
+
message: `Service '${serviceName}' appears to mount the host root filesystem ('${raw}').`,
|
|
135
192
|
service: serviceName,
|
|
136
193
|
path: `${targetPath}#services.${serviceName}.volumes`,
|
|
137
194
|
suggestion: 'Avoid mounting /. Mount only specific directories required by the app.'
|
|
138
195
|
});
|
|
139
196
|
}
|
|
140
|
-
if (
|
|
197
|
+
if (raw.startsWith('/dev:/dev') || raw.startsWith('/dev/:/dev') || hostPath === '/dev') {
|
|
141
198
|
findings.push({
|
|
142
199
|
id: 'compose.host-dev-mount',
|
|
143
200
|
title: 'Host /dev mounted into container',
|
|
144
201
|
severity: 'high',
|
|
145
|
-
message: `Service '${serviceName}' mounts host /dev into the container ('${
|
|
202
|
+
message: `Service '${serviceName}' mounts host /dev into the container ('${raw}'), which can enable device access and privilege escalation.`,
|
|
146
203
|
service: serviceName,
|
|
147
204
|
path: `${targetPath}#services.${serviceName}.volumes`,
|
|
148
205
|
suggestion: 'Avoid mounting /dev. If hardware access is required, map only the specific device(s) needed via devices:.'
|
|
@@ -204,6 +261,19 @@ export function runRules(compose, targetPath) {
|
|
|
204
261
|
suggestion: 'Set user: "1000:1000" (or a dedicated UID/GID) and ensure the image supports running unprivileged.'
|
|
205
262
|
});
|
|
206
263
|
}
|
|
264
|
+
// Rule: filesystem not read-only (hardening)
|
|
265
|
+
// Low severity because many images expect write access unless explicitly designed for read-only.
|
|
266
|
+
if (svc?.read_only !== true) {
|
|
267
|
+
findings.push({
|
|
268
|
+
id: 'compose.missing-read-only',
|
|
269
|
+
title: 'Filesystem not set to read-only',
|
|
270
|
+
severity: 'low',
|
|
271
|
+
message: `Service '${serviceName}' does not set read_only: true (container filesystem is writable by default).`,
|
|
272
|
+
service: serviceName,
|
|
273
|
+
path: `${targetPath}#services.${serviceName}.read_only`,
|
|
274
|
+
suggestion: 'Consider setting read_only: true + add explicit writable mounts (e.g. tmpfs:/tmp or a data volume) if the app supports it.'
|
|
275
|
+
});
|
|
276
|
+
}
|
|
207
277
|
// Rule: exposed sensitive ports
|
|
208
278
|
const ports = normalizePorts(svc?.ports);
|
|
209
279
|
for (const p of ports) {
|