feat(health): catch credentials inline in committed systemd units #5618
2 changed files+99−2
Addedsrc/__tests__/unit-file-credentials.test.ts+82−0View fileUnifiedSplit
@@ -0,0 +1,82 @@
1/**
2 * Credentials inline in a committed systemd unit.
3 *
4 * Reported by the Vapron instance from a live 2026-08-28 incident on its
5 * box: `systemctl show <unit> -p Environment` printed a Postgres password,
6 * an OAuth client secret, a JWT signing key and a Maps API key — readable by
7 * an UNPRIVILEGED account, because systemd publishes Environment= over D-Bus
8 * and `systemctl show` is not privileged. File permissions are irrelevant;
9 * chmod does nothing. The fix is EnvironmentFile=, which D-Bus exposes as a
10 * path rather than the values.
11 *
12 * A repository scanner reaches only the committed subset of that class — a
13 * unit edited live on a box is invisible here by construction, and the rule's
14 * scope says so rather than implying runtime coverage it does not have. But
15 * unit files DO get committed, .service was not even in the eligible file
16 * set, and no existing rule matched the `Environment="KEY=value"` shape:
17 * the credential word sits BEFORE the inner `=`, with the quote before it,
18 * which none of the generic patterns fit.
19 */
20import { describe, expect, it } from "bun:test";
21import { detectSecurityIssuesInLine } from "../lib/intelligence";
22
23const rules = (filePath: string, line: string) =>
24 detectSecurityIssuesInLine({ filePath, line, lineNumber: 1 }).map((i) => i.rule);
25
26// Runtime-assembled realistic value: mixed case + digits, nothing placeholder.
27const VALUE = "Xk" + "9" + "mQ" + "4" + "vTz" + "8" + "pLw" + "2";
28
29describe("inline unit credentials are found", () => {
30 it("catches the incident shape: a database password inline", () => {
31 expect(
32 rules("deploy/api.service", `Environment="DATABASE_PASSWORD=${VALUE}"`)
33 ).toContain("no-unit-inline-credentials");
34 });
35
36 it("catches unquoted form and timer units", () => {
37 expect(rules("infra/sync.timer", `Environment=API_TOKEN=${VALUE}`)).toContain(
38 "no-unit-inline-credentials"
39 );
40 });
41
42 it("catches a JWT signing key", () => {
43 expect(
44 rules("units/web.service", `Environment="JWT_SIGNING_SECRET=${VALUE}"`)
45 ).toContain("no-unit-inline-credentials");
46 });
47});
48
49describe("non-credentials in Environment= stay silent", () => {
50 it("build metadata is not a credential", () => {
51 // scripts/self-deploy.sh writes exactly this shape — the only
52 // Environment= this repo produces, checked before writing the rule.
53 expect(rules("deploy/app.service", 'Environment="BUILD_SHA=abc123def456"')).toEqual([]);
54 expect(rules("deploy/app.service", 'Environment="NODE_ENV=production"')).toEqual([]);
55 });
56
57 it("a specifier or substitution is not a committed value", () => {
58 expect(rules("deploy/app.service", 'Environment="API_TOKEN=${HOST_TOKEN}"')).toEqual([]);
59 });
60
61 it("a placeholder value is not a finding", () => {
62 expect(
63 rules("deploy/app.service", 'Environment="API_TOKEN=<your-token-here>"')
64 ).toEqual([]);
65 });
66
67 it("EnvironmentFile= — the correct form — never fires", () => {
68 expect(rules("deploy/app.service", "EnvironmentFile=/opt/app/.env")).toEqual([]);
69 });
70
71 it("prose describing the rule's own subject does not fire", () => {
72 // The describers-of-directives class, again: a runbook explaining WHY
73 // EnvironmentFile= is required will name the dangerous form. Scoped via
74 // appliesTo: NOT_PROSE, not by luck of a short example value — the first
75 // draft of this test passed only because "..." is under the length
76 // floor, which would have let a runbook quoting a real-length example
77 // fire. Asserted with one at full length so the scoping is load-bearing.
78 expect(
79 rules("docs/runbooks/deploy.md", `Never write Environment="DB_PASSWORD=${VALUE}" — use EnvironmentFile=.`)
80 ).toEqual([]);
81 });
82});
Modifiedsrc/lib/intelligence.ts+17−2View fileUnifiedSplit
@@ -491,6 +491,7 @@ const NOT_PROSE = [
491491 "ts", "tsx", "js", "jsx", "mjs", "cjs", "py", "rb", "go", "rs", "java",
492492 "php", "sh", "bash", "zsh", "yaml", "yml", "json", "toml", "ini", "cfg",
493493 "conf", "properties", "tf", "tfvars", "xml", "gradle",
494 "service", "timer", "socket",
494495];
495496/** Languages that have an `eval` construct of their own. */
496497const EVAL_LANGS = [...JS_TS, "py", "rb", "php"];
@@ -534,6 +535,20 @@ const SECURITY_PATTERNS: Array<{
534535 // author believes the env var will be set, and the literal is live in every
535536 // environment where it is not.
536537 { pattern: /(?:password|passwd|pwd|secret|api[_-]?key|apikey|token|credential)[A-Za-z0-9_]*\s*[:=]\s*[^;]*?(?:\?\?|\|\|)\s*["']([^"']{4,})["']/i, severity: "critical", message: "Hardcoded credential used as a fallback when the environment variable is unset", rule: "no-hardcoded-secrets", appliesTo: NOT_PROSE },
538 // A credential inline in a systemd unit is WORSE than one in a config file,
539 // and the reason is not file permissions: systemd publishes Environment=
540 // over D-Bus, and `systemctl show <unit> -p Environment` is unprivileged.
541 // Any local account reads every inline value regardless of file mode —
542 // chmod does nothing. Reported from a live incident on a peer platform
543 // (2026-08-28): a Postgres password, an OAuth client secret, a JWT signing
544 // key and a Maps API key, all readable by an unprivileged user. The fix is
545 // EnvironmentFile=, which D-Bus exposes as a PATH rather than the values.
546 //
547 // A repo scanner only reaches the committed subset of that class — a
548 // deployed unit edited on a box is invisible here by construction, and
549 // saying otherwise would be the coverage lie this module keeps un-learning.
550 // But customers commit their unit files, and this is the shape to catch.
551 { pattern: /^\s*Environment\s*=\s*"?[A-Za-z_]*(?:PASSWORD|PASSWD|SECRET|TOKEN|API[_-]?KEY|APIKEY|CREDENTIAL)[A-Za-z0-9_]*=([^"\s]{8,})/i, severity: "critical", message: "Credential inline in systemd Environment= — systemctl show publishes these over D-Bus to any local account; use EnvironmentFile=", rule: "no-unit-inline-credentials", appliesTo: NOT_PROSE },
537552 { pattern: /(?:AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}/, severity: "critical", message: "Possible AWS access key", rule: "no-aws-keys" },
538553 // OPENSSH is the DEFAULT format ssh-keygen has produced since 7.8, and it
539554 // was not matched — nor were DSA or PGP blocks. A private key is the least
@@ -1064,7 +1079,7 @@ export function detectSecurityIssuesInLine(args: {
10641079 // Mention is not use: a call-shaped rule only fires from code.
10651080 if (rule.codeOnly && !patternMatchesInCode(line, rule.pattern)) continue;
10661081
1067 if (rule.rule === "no-hardcoded-secrets") {
1082 if (rule.rule === "no-hardcoded-secrets" || rule.rule === "no-unit-inline-credentials") {
10681083 // Prefer the rule's own capture when it has one. The env-fallback rule
10691084 // captures the literal after `??`/`||`, which is the actual credential;
10701085 // taking the first quoted string on the line instead would pick up a
@@ -1206,7 +1221,7 @@ async function analyzeSecurityScore(
12061221 // and `innerHTML` cannot fire on a Markdown file, while the credential,
12071222 // AWS-key and private-key rules — which are language-agnostic and are
12081223 // exactly the ones that matter in a README — can.
1209 /\.(ts|tsx|js|jsx|mjs|cjs|py|rb|go|rs|java|php|sh|bash|zsh|yaml|yml|json|md|markdown|toml|ini|cfg|conf|properties|tf|tfvars|xml|gradle|txt)$/.test(f)
1224 /\.(ts|tsx|js|jsx|mjs|cjs|py|rb|go|rs|java|php|sh|bash|zsh|yaml|yml|json|md|markdown|toml|ini|cfg|conf|properties|tf|tfvars|xml|gradle|txt|service|timer|socket)$/.test(f)
12101225 );
12111226 // ONE `git grep -n` returns every candidate line in the whole tree, so the
12121227 // per-file read disappears and coverage becomes complete. The scanner used
12131228
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts