<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Technical Docs]]></title><description><![CDATA[Technical Docs]]></description><link>https://devseccloud.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Fri, 23 Oct 2026 17:45:19 GMT</lastBuildDate><atom:link href="https://devseccloud.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Why Your Database Will Crash in a Kubernetes Deployment (And Why You Need a StatefulSet)]]></title><description><![CDATA[The subtle multi-attach volume bug that takes down databases during rolling updates—and how StatefulSets and headless services solve it.]]></description><link>https://devseccloud.hashnode.dev/why-your-database-will-crash-in-a-kubernetes-deployment-and-why-you-need-a-statefulset</link><guid isPermaLink="true">https://devseccloud.hashnode.dev/why-your-database-will-crash-in-a-kubernetes-deployment-and-why-you-need-a-statefulset</guid><category><![CDATA[Kubernetes]]></category><category><![CDATA[MongoDB]]></category><category><![CDATA[statefulsets]]></category><category><![CDATA[Devops]]></category><category><![CDATA[DevSecOps]]></category><dc:creator><![CDATA[Sulakshana Karunarathne]]></dc:creator><pubDate>Thu, 22 Oct 2026 07:43:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad0cce04122668505acf0ac/bcba4215-0779-47f1-9c5e-db8d6910aaea.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>The Naive Mistake: "Everything is a Deployment"</h2>
<p>When developers first start containerizing applications for Kubernetes, it is tempting to treat every workload the same way:</p>
<pre><code class="language-yaml">apiVersion: apps/v1
kind: Deployment # ⚠️ The hidden trap for databases
metadata:
  name: mongodb
</code></pre>
<p>It works fine on day one. You deploy MongoDB, attach a <code>PersistentVolumeClaim</code> (PVC), and your backend connects happily.</p>
<p>Then, you trigger your first rolling update—maybe bumping the image version from <code>mongo:8.0</code> to <code>mongo:9.0</code>. </p>
<p>Suddenly, your deployment hangs. Your pods enter an endless <code>ContainerCreating</code> crash loop, and your application goes completely offline.</p>
<p>What happened? You just hit the dreaded <strong>Multi-Attach Volume Error</strong>.</p>
<hr />
<h2>The Autopsy: The Multi-Attach Disaster</h2>
<p>Kubernetes <code>Deployments</code> were engineered for <strong>stateless applications</strong> (like a React frontend or an Express API). In a stateless deployment:</p>
<ol>
<li>Pods are completely disposable and have random hash names (e.g., <code>backend-58f9ddc96-bwsfc</code>).</li>
<li>During a rolling update, Kubernetes uses <code>maxSurge</code> by default: it starts the <strong>new pod</strong> <em>before</em> terminating the <strong>old pod</strong> to achieve zero-downtime.</li>
</ol>
<p>Here is why that breaks stateful databases:</p>
<pre><code class="language-text">[Old Pod: mongodb-7c89f] ──(Holds Disk Lock)──▶ [PersistentVolume (RWO)]
                                                        ▲
[New Pod: mongodb-992ab] ──(Tries to Mount)─────────────┘
  ❌ FATAL ERROR: Multi-Attach error for volume. 
     Volume is already exclusively attached to one node.
</code></pre>
<p>Cloud storage volumes (like AWS EBS or digital block storage) almost always have an access mode of <strong><code>ReadWriteOnce</code> (RWO)</strong>. This means the disk can only be attached to <strong>one node at a time</strong>.</p>
<p>Because Kubernetes started the new pod before terminating the old one, both pods attempted to mount the exact same disk simultaneously. The storage driver rejected the mount, leaving the new pod stuck and freezing your database.</p>
<hr />
<h2>The Cure: Upgrading to a <code>StatefulSet</code></h2>
<p>To safely run stateful storage workloads like MongoDB, PostgreSQL, or Redis in Kubernetes, you must use a <strong><code>StatefulSet</code></strong>.</p>
<p>Here is the hardened MongoDB StatefulSet manifest I implemented in <a href="https://github.com/Sulakshana29/deal-aggregator">DealVault</a>:</p>
<pre><code class="language-yaml">apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: mongodb
  namespace: deal-aggregator
spec:
  serviceName: mongodb # Links to our headless service
  replicas: 1
  selector:
    matchLabels:
      app: mongodb
  template:
    metadata:
      labels:
        app: mongodb
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 999 # Non-root mongodb user
        fsGroup: 999
      containers:
        - name: mongodb
          image: mongo:9.0.2
          ports:
            - containerPort: 27017
          volumeMounts:
            - name: data
              mountPath: /data/db
  # The magic happens here:
  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
        accessModes: ["ReadWriteOnce"]
        resources:
          requests:
            storage: 1Gi
</code></pre>
<hr />
<h2>3 Reasons Why StatefulSets Save Your Database</h2>
<h3>1. Predictable, Stable Identity</h3>
<p>Unlike Deployments with randomized pod names, StatefulSet pods get sticky, ordered names:
<code>mongodb-0</code>, <code>mongodb-1</code>, etc.
When <code>mongodb-0</code> restarts, it comes back as <code>mongodb-0</code>. It never changes identity.</p>
<h3>2. Sequential, Safe Updates</h3>
<p>A StatefulSet never starts a second pod while the first one is still attached to storage. When updating, it executes an orderly, sequential sequence:</p>
<ol>
<li>Sends a <code>SIGTERM</code> to <code>mongodb-0</code>.</li>
<li>Flushes database writes cleanly to disk.</li>
<li>Unmounts the volume.</li>
<li>Starts the updated container and re-attaches the existing volume.
<strong>Zero volume lock collisions.</strong></li>
</ol>
<h3>3. Dedicated <code>volumeClaimTemplates</code></h3>
<p>Instead of having a separate PVC YAML file that multiple pods might accidentally fight over, the <code>volumeClaimTemplates</code> block dynamically provisions a unique, permanently bonded disk for that specific pod index (<code>data-mongodb-0</code>). Even if the pod is rescheduled onto a different server, Kubernetes re-binds that exact disk volume.</p>
<hr />
<h2>Don't Forget the Headless Service (<code>clusterIP: None</code>)</h2>
<p>A standard Kubernetes <code>Service</code> acts as a round-robin load balancer via a virtual cluster IP. But with a database, you don't want a load balancer proxying traffic randomly—you need direct communication with the exact database pod.</p>
<p>We use a <strong>Headless Service</strong>:</p>
<pre><code class="language-yaml">apiVersion: v1
kind: Service
metadata:
  name: mongodb
  namespace: deal-aggregator
spec:
  clusterIP: None # 👈 "Headless" (No virtual IP)
  selector:
    app: mongodb
  ports:
    - port: 27017
      targetPort: 27017
</code></pre>
<p>By setting <code>clusterIP: None</code>, Kubernetes DNS bypasses the proxy and returns the direct pod IP, giving our database a permanent internal DNS address:
<code>mongodb-0.mongodb.deal-aggregator.svc.cluster.local</code> (or simply <code>mongodb</code> within the namespace).</p>
<hr />
<h2>The Verdict</h2>
<table>
<thead>
<tr>
<th align="left">Requirement</th>
<th align="left">Deployment</th>
<th align="left">StatefulSet</th>
</tr>
</thead>
<tbody><tr>
<td align="left"><strong>Best For</strong></td>
<td align="left">Stateless APIs, Web Frontends</td>
<td align="left">Databases, Queues, Cache stores</td>
</tr>
<tr>
<td align="left"><strong>Pod Naming</strong></td>
<td align="left">Random hashes (<code>app-7f89d-xk</code>)</td>
<td align="left">Ordered indexes (<code>mongodb-0</code>)</td>
</tr>
<tr>
<td align="left"><strong>Rolling Updates</strong></td>
<td align="left">Starts new pod before killing old</td>
<td align="left">Ordered sequential restarts</td>
</tr>
<tr>
<td align="left"><strong>Storage Pattern</strong></td>
<td align="left">Shared PVC (risk of multi-attach)</td>
<td align="left">Dedicated <code>volumeClaimTemplates</code></td>
</tr>
<tr>
<td align="left"><strong>Networking</strong></td>
<td align="left">Virtual ClusterIP load balancing</td>
<td align="left">Direct Headless Service DNS</td>
</tr>
</tbody></table>
<p>If your container writes data to disk that needs to survive restarts, <strong>do not use a Deployment</strong>. Use a <code>StatefulSet</code> with <code>volumeClaimTemplates</code> and a headless service to ensure your data stays intact through every deployment cycle.</p>
<hr />
<p><em>This architecture was implemented and verified as part of <a href="https://github.com/Sulakshana29/deal-aggregator">DealVault</a>, an open-source DevSecOps portfolio project featuring automated CI testing on Kubernetes (KinD).</em></p>
]]></content:encoded></item><item><title><![CDATA[Catching over-permissive IAM in the pull request, not in production]]></title><description><![CDATA[Why I built Cloud Policy Guard: a small Python CLI that reads a Terraform plan, scores IAM risk, and fails the pipeline before a wildcard policy ever…]]></description><link>https://devseccloud.hashnode.dev/catching-over-permissive-iam-in-the-pull-request</link><guid isPermaLink="true">https://devseccloud.hashnode.dev/catching-over-permissive-iam-in-the-pull-request</guid><category><![CDATA[Terraform]]></category><category><![CDATA[AWS IAM]]></category><category><![CDATA[policy as code]]></category><category><![CDATA[ci-cd]]></category><dc:creator><![CDATA[Sulakshana Karunarathne]]></dc:creator><pubDate>Thu, 15 Oct 2026 13:28:02 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad0cce04122668505acf0ac/d74ca931-bb0b-4d26-b58d-9a7d169ce836.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Over-permissive IAM is one of the most common AWS misconfigurations: <code>"Action": "*"</code>, <code>iam:PassRole</code> granted on every resource, a trust policy with <code>"Principal": "*"</code>. The problem is usually found during an audit, <em>after</em> the policy has already been live for weeks.</p>
<p>I wanted that check to happen where the change is made: in the pull request. So I built <strong>Cloud Policy Guard</strong>, a Policy-as-Code security gate for Terraform.</p>
<h2>Scan the plan, not the account</h2>
<p>The tool reads the JSON form of a Terraform plan, which describes exactly what Terraform is about to create:</p>
<pre><code class="language-bash">terraform plan -out=tfplan
terraform show -json tfplan &gt; plan.json
cloud-policy-guard scan --terraform-plan plan.json
</code></pre>
<p>Because it works on the plan file, it runs <strong>fully offline</strong> and needs no AWS credentials. Nothing has to be deployed, and no secrets have to be handed to the CI job.</p>
<h2>The rules</h2>
<table>
<thead>
<tr>
<th>ID</th>
<th>Detects</th>
<th>Severity</th>
</tr>
</thead>
<tbody><tr>
<td>LP001</td>
<td>Wildcard action, <code>Action: "*"</code></td>
<td>Critical</td>
</tr>
<tr>
<td>LP002</td>
<td>Wildcard resource, <code>Resource: "*"</code> (context-aware)</td>
<td>High / Medium</td>
</tr>
<tr>
<td>LP003</td>
<td>Administrative permissions (<code>*</code> on <code>*</code>, or admin managed policies)</td>
<td>Critical</td>
</tr>
<tr>
<td>LP004</td>
<td>Sensitive IAM actions such as <code>iam:PassRole</code>, <code>iam:PutRolePolicy</code></td>
<td>High</td>
</tr>
<tr>
<td>LP005</td>
<td>Service-level wildcards such as <code>s3:*</code>, <code>iam:*</code>, <code>secretsmanager:*</code></td>
<td>High / Medium</td>
</tr>
<tr>
<td>LP006</td>
<td>Broad trust policies, <code>Principal: "*"</code> or insecure cross-account trust</td>
<td>Critical</td>
</tr>
</tbody></table>
<p>The rules are context-aware: they evaluate the combination of actions and resources to set the severity, instead of flagging every wildcard the same way.</p>
<h2>Turning findings into a pass/fail decision</h2>
<p>Each finding adds to a deterministic risk score: Critical = 10, High = 7, Medium = 4, Low = 1. A pipeline fails if any finding reaches the <code>fail_on</code> severity, or if the total score passes <code>max_risk_score</code>:</p>
<pre><code class="language-yaml"># .cpg.yaml
fail_on: critical       # fail if any finding is critical or worse
max_risk_score: 50      # fail if the total risk score exceeds 50
exclude_rules:
  - LP005               # rules can be disabled per repo
format: text            # text for humans, json for pipelines
</code></pre>
<p>The exit code does the rest: <strong>0 for PASS, 1 for FAIL</strong>. Any CI system (GitHub Actions, GitLab CI, Jenkins) blocks the merge without extra integration work.</p>
<h2>In a GitHub Actions workflow</h2>
<pre><code class="language-yaml">- name: Terraform Plan
  run: |
    terraform init
    terraform plan -out=tfplan
    terraform show -json tfplan &gt; plan.json

- name: Scan IAM least privilege
  # exit code 1 fails the job and blocks the PR
  run: cloud-policy-guard scan --terraform-plan plan.json
</code></pre>
<h2>Testing it honestly</h2>
<p>The repo ships with a deliberately vulnerable Terraform example and a secure one. The vulnerable plan must FAIL and the secure plan must PASS, and a pytest suite keeps the rules from regressing. The tool is also containerised with Docker, so it behaves the same on my laptop and in CI.</p>
<h2>What I took away</h2>
<ul>
<li><p>Shifting left only works if the check is fast, offline and gives a clear yes/no.</p>
</li>
<li><p>A severity model plus a score threshold lets teams adopt a gate gradually instead of all at once.</p>
</li>
<li><p>Two outputs matter: coloured text that developers will actually read, and JSON that pipelines can parse.</p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Hardening a 3-tier app before Kubernetes: 6 things that were quietly broken]]></title><description><![CDATA[Before moving Deal Aggregator to Kubernetes I audited it the way an attacker would. Public delete endpoints, a ReDoS hole, an unauthenticated database and…]]></description><link>https://devseccloud.hashnode.dev/hardening-a-3-tier-app-before-kubernetes</link><guid isPermaLink="true">https://devseccloud.hashnode.dev/hardening-a-3-tier-app-before-kubernetes</guid><category><![CDATA[DevSecOps]]></category><category><![CDATA[Docker]]></category><category><![CDATA[node js]]></category><category><![CDATA[nginx]]></category><dc:creator><![CDATA[Sulakshana Karunarathne]]></dc:creator><pubDate>Thu, 15 Oct 2026 13:24:49 GMT</pubDate><content:encoded><![CDATA[<p>Kubernetes gives you strong security controls: <code>runAsNonRoot</code>, read-only root filesystems, readiness-based routing. But they only work if your containers already support them. So before moving <strong>Deal Aggregator</strong> (React → Express → MongoDB, running on EC2 with Docker Compose) onto Kubernetes, I ran a dedicated hardening pass.</p>
<p>The goal was simple: make the app secure <em>by itself</em>, and make every security claim in the README actually true. That second part turned out to matter more than I expected.</p>
<h2>1. Anyone could delete the database</h2>
<p><code>POST /deals</code> and <code>DELETE /deals/:id</code> were public. The UI had an "Admin mode" toggle, but it only hid a button in the browser. This worked for anyone:</p>
<pre><code class="language-bash">curl -X DELETE http://&lt;host&gt;/api/deals/&lt;id&gt;
</code></pre>
<p><strong>Fix:</strong> I removed the write routes entirely instead of adding auth. Nothing legitimate used them, since the scraper and seed script write to MongoDB directly over the internal network. The most secure endpoint is one that doesn't exist (the principle of <em>least functionality</em>). If an admin UI is ever needed, it should come with real authentication, not a client-side flag.</p>
<h2>2. One request could freeze the API</h2>
<p>Search input went straight from the URL into a regular expression:</p>
<pre><code class="language-javascript">filter[key] = { $regex: new RegExp(`^${val}$`, 'i') };
</code></pre>
<p>That causes two problems:</p>
<ul>
<li><p><strong>ReDoS:</strong> <code>?search=(a+)+$</code> compiles into a pattern that backtracks exponentially. Node.js is single-threaded, so <em>one</em> request can stall the API for every user.</p>
</li>
<li><p><strong>NoSQL operator injection:</strong> Express parses <code>?bank[$ne]=x</code> into the object <code>{ $ne: 'x' }</code>. Objects should never reach a Mongo filter.</p>
</li>
</ul>
<p><strong>Fix:</strong> every regex metacharacter is escaped so input matches literally, query params must be plain strings, and search is capped at 100 characters.</p>
<h2>3. Rate limiting that limited everyone at once</h2>
<p>Every request reaches Express <em>from nginx</em>, so <code>req.ip</code> was always the nginx container's IP. All users shared one rate-limit bucket: one abusive client could lock everybody out.</p>
<p><strong>Fix:</strong> <code>app.set('trust proxy', 1)</code> trusts exactly one hop and reads the real client IP from <code>X-Forwarded-For</code>, which nginx now sets. Trusting <em>all</em> hops would be worse, because clients could then spoof their IP with a fake header.</p>
<h2>4. The database had no password (and the README said it did)</h2>
<p>This was the uncomfortable one. MongoDB ran without authentication while the README described it as secured.</p>
<p><strong>Fix:</strong> root credentials now come from a git-ignored <code>.env</code>, and Compose uses the <code>${VAR:?message}</code> syntax so it <strong>refuses to start</strong> if they are missing. It cannot silently fall back to "no password".</p>
<h2>5. Internal tools exposed to the internet</h2>
<p>On the EC2 host, ports for the backend, Prometheus, cAdvisor and Loki were all published, and Grafana used <code>admin</code>/<code>admin</code>. Each one is an unauthenticated internal tool sitting on the public internet, and cAdvisor even exposes host details.</p>
<p><strong>Fix:</strong> only port 80 is public now. Grafana binds to <code>127.0.0.1</code> and requires a password, so I reach it through an SSH tunnel. nginx also returns 404 for <code>/api/metrics</code>; Prometheus scrapes the backend directly over the internal network.</p>
<pre><code class="language-bash">ssh -L 3001:localhost:3001 &lt;user&gt;@&lt;ec2-host&gt;
</code></pre>
<h2>6. Containers running as root</h2>
<p><strong>Fix:</strong> the backend and scraper images end with <code>USER node</code>, and the code is copied in as root, so the runtime user can read the app but not modify it. The frontend moved to <code>nginx-unprivileged</code> (UID 101, port 8080), which Kubernetes needs for <code>runAsNonRoot</code>. Builds use <code>npm ci --omit=dev</code> for exact lockfile versions and no dev tooling in the image, which also means fewer CVEs for Trivy to report. Image versions are pinned instead of <code>:latest</code>.</p>
<h2>Smaller fixes that still mattered</h2>
<ul>
<li><p><strong>Secrets in logs:</strong> the backend logged the full <code>MONGO_URI</code>, password included, and Promtail shipped every line into Loki. Now only host and DB name are logged.</p>
</li>
<li><p><strong>Liveness vs readiness:</strong> <code>/health</code> only checks that the process is alive; <code>/ready</code> checks MongoDB. If the database is down, restarting the API fixes nothing, so liveness must never check dependencies.</p>
</li>
<li><p><strong>Graceful shutdown:</strong> on <code>SIGTERM</code> the API stops accepting connections, finishes in-flight requests, closes MongoDB and exits. The Dockerfile uses exec-form <code>CMD</code> so node is PID 1 and actually receives the signal.</p>
</li>
<li><p><strong>Headers:</strong> <code>helmet</code> on the API; CSP, <code>X-Frame-Options: DENY</code>, <code>nosniff</code> and <code>server_tokens off</code> on nginx.</p>
</li>
</ul>
<h2>How I verify it</h2>
<p>Every fix has a check I can rerun:</p>
<pre><code class="language-bash">curl -s localhost:3000/api/ready                                              # {"status":"ready","db":true}
curl -s -o /dev/null -w '%{http_code}\n' localhost:3000/api/metrics          # 404
curl -s -o /dev/null -w '%{http_code}\n' -X DELETE localhost:3000/api/deals/x # 404
curl -s "localhost:3000/api/deals?search=(a%2B)%2B%24"                        # returns instantly
docker compose exec backend whoami                                            # node
docker compose exec frontend id -u                                            # 101
</code></pre>
<h2>What I took away</h2>
<ul>
<li><p>A security claim in a README is a liability until something tests it.</p>
</li>
<li><p>Removing a feature is often a better fix than securing it.</p>
</li>
<li><p>Container-level hardening has to come <em>before</em> Kubernetes, not after.</p>
</li>
</ul>
<p>One known gap remains: MongoDB inside Kubernetes still runs without auth. The next phase moves it to a StatefulSet with a Secret, along with NetworkPolicies and Pod Security Admission.</p>
]]></content:encoded></item></channel></rss>