Security Smell Checks

Security smell checks are systematic inspections of code, configuration, and runtime state that reveal anti‑patterns, weak defaults, and misconfigurations that often go unnoticed until they’re exploited. They matter when you’re reviewing a system that handles secrets, network exposure, or access control—before deployment, after config changes, or as part of a recurring hygiene cycle. Unlike full vulnerability scans, smell checks target the patterns and defaults that are easy to overlook but repeatedly show up in incidents: a token left in a Dockerfile, a service account with more privileges than needed, a listener bound on 0.0.0.0, or a cipher suite negotiated down to something weak. The goal is to catch the “odor” before it becomes a breach.

Find Hardcoded Secrets in Repos

Secrets embedded in source history, config files, or CI artifacts are a perennial source of leaks. Two widely available tools can surface them without requiring a commercial platform.

trufflehog scans git history and working directories for high‑entropy strings that look like credentials.

trufflehog --regex --only-verified --no-git .

The --regex flag restricts matching to known secret patterns, --only-verified reduces false positives by requiring a minimum entropy threshold, and --no-git limits the scan to the current working tree rather than the full history. Typical output when a match is found:

[+] Found secret: [email protected]:team/api-key (entropy: 47.2, pattern: api.key)

If no matches are found, the tool exits cleanly with no output and a zero exit code.

gitleaks offers a similar capability with a focus on repository‑wide detection.

gitleaks detect --no-git -r .

The --no-git flag skips git‑specific history traversal, and -r . tells it to recurse into submodules. A clean scan produces no output and exits with code 0; a detected secret prints a line like:

ERROR: leaked secret found: .env: API_SECRET=sk_live_12345abcdef

Both tools can be driven from CI pipelines, but they’re equally useful as one‑off checks on a developer’s workstation.

Audit RBAC and Service Account Privileges

Mis‑privileged service accounts are a direct path to lateral movement. Checking what a given account can actually do requires querying the authorization layer directly.

kubectl can test a user’s or service account’s effective permissions against the cluster’s RBAC rules.

kubectl auth can-i '*' '*' --all-namespaces --as=system:serviceaccount:prod:api-server

If RBAC is enabled (the apiserver flag --authorization-mode=RBAC is set), the command returns either yes or no for each verb‑resource pair. If RBAC is not configured, the apiserver may either reject the request with Error: the server could not find the requested resource (when the auth plugin is missing) or silently allow all actions, which is a common silent failure mode after cluster upgrades.

Inside PostgreSQL, role privileges can be inspected with a direct query:

psql -U postgres -c "SELECT rolname, rolsuper, rolcreatedb, rolcanlogin FROM pg_roles WHERE rolname = 'api_service';"

Output might look like:

 rolname  | rolsuper | rolcreatedb | rolcanlogin
----------+----------+-------------+-------------
 api_service | f        | t           | t

This tells you the role isn’t a superuser, can create databases, and can log in. To see membership and inherited privileges, you’ll also need:

psql -U postgres -c "SELECT rolname, member FROM pg_auth_members WHERE roleid = (SELECT oid FROM pg_roles WHERE rolname = 'api_service');"

A frequent sharp edge: pg_roles does not transitive‑close membership. If role A is granted to B, and B to C, C does not automatically inherit A’s privileges unless the query explicitly walks the graph. Many auditors miss this and assume broader access than actually exists.

Check Service and Network Exposure

What a service advertises on the network often differs from what’s intended, especially in containerized or virtualized environments.

docker inspect can reveal the exact network configuration a container was started with:

docker inspect --format='{{json .NetworkSettings}}' my_web_container

The JSON output includes Ports, SandboxKey, and NetworkMode. A common misconfiguration is finding PortMapping entries that expose internal ports (e.g., 8080/tcp) to the host without an explicit --publish flag, often left over from docker run commands that used --net=host.

curl with TLS‑specific flags can verify what protocols and cipher suites a service actually accepts:

curl -v --tlsv1.3 --ciphers ECDHE-ECDSA-AES256-GCM-SHA384 https://internal.service.local 2>&1 | grep -E 'SSL|CIPHER|PROTOCOL'

If the server supports only older versions, you’ll see output like SSL connection using TLSv1.2 and CIPHER is ECDHE-RSA-AES128-GCM-SHA256. A silent failure occurs when --tlsv1.3 is used against a server that only speaks TLS 1.2: curl will either fall back silently (depending on OpenSSL version) or abort with schannel: next client protocol version 0x0304 (TLS 1.3) not accepted. Checking the server’s response helps confirm whether the client’s security baseline is being enforced or silently downgraded.

Validate Config Defaults and Hardening

System services and their unit files often carry defaults that are wider‑open than a production policy would allow.

systemctl can dump the active unit file, showing the exact ExecStart, LimitNOFILE, and ProtectSystem settings:

systemctl cat sshd

Typical output includes lines like:

[Service]
ExecStart=/usr/sbin/sshd -D
LimitNOFILE=1024
ProtectSystem=full

If ProtectSystem is absent or set to no, the SSH daemon has full filesystem access, which is a smell in hardened environments. Similarly, LimitNOFILE=1024 may be insufficient for high‑throughput workloads, leading to silently dropped connections that appear as network timeouts.

Config files for applications often embed defaults that are easy to miss when reviewing diffs. A typical pattern is to grep for bind-address or listen directives:

grep -rn "bind-address\|listen\s*=" /etc/myapp/ /etc/myapp.d/

Output might reveal bind-address = 0.0.0.0 in a production config, meaning the service listens on every interface. The sharp edge here is that some frameworks honor a BIND_ADDR environment variable that overrides the config file at runtime, so a change to the file alone won’t take effect without a restart or service reload.

Sharp Edges: What Silently Fails, Confused Options, Production Breakage

  • trufflehog may flag high‑entropy strings in binary blobs, compressed data, or base64‑encoded payloads, producing false positives that noise downstream alerts. Conversely, secrets stored as plain‑text in environment variables exported by a CI job but not committed to git will be missed unless the scanner explicitly reads the job’s secret store.
  • kubectl auth can-i is only as authoritative as the apiserver’s authorization mode. If the cluster was started with --authorization-mode=Node,RBAC, the command may still return yes for nodes that bypass the API server’s auth loop. Always verify the active mode with ps aux | grep kube-apiserver | grep authorization-mode.
  • psql role membership is non‑transitive by default. Auditors often assume that GRANT role_a TO role_b; GRANT role_b TO role_c; gives role_c all of role_a’s privileges, but PostgreSQL does not close the graph automatically. A custom function or recursive CTE is required for a complete audit.
  • curl --tlsv1.3 can error out on servers that only negotiate TLS 1.2, but the exact failure mode depends on the OpenSSL library version. Older versions (pre‑1.1.1) will simply return Unable to perform TLS handshake without indicating the protocol mismatch, leading operators to incorrectly assume the server is down rather than that the client’s protocol version is incompatible.
  • systemctl cat displays the unit file that systemd loaded, but it does not show drop‑in overrides unless you also run systemctl cat --no-pager and pipe through systemctl show -p DropInPaths. A common oversight is believing the base unit is the only source of configuration when a masked unit or a drop‑in drop‑in directory is silently taking precedence.
  • docker inspect’s NetworkSettings JSON can be massive and hard to parse in a shell one‑liner. Using --format with specific template directives (as shown) avoids the noise, but if the container was started with --network=none, the output may omit PortMapping entirely, making it appear as though no ports are exposed when in fact the container has no network stack at all.

When It Matters

Security smell checks are most valuable as a recurring, low‑overhead layer in your deployment pipeline. Run them before every merge to catch newly introduced secrets or overly permissive rules. Run them after any infrastructure‑as‑code change to verify that defaults haven’t drifted. And run them as part of a post‑incident review to determine whether a smell was present but missed, or whether a new one emerged from the response. They don’t replace comprehensive scanning or penetration testing, but they surface the patterns that those deeper tests often find after the fact.

This page was rewritten on 11 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.