courses

Application Security

Building security into the software, not bolting it on

Application security (AppSec) encompasses the practices, tools, and techniques used to find, fix, and prevent security vulnerabilities in software. The modern approach automates AppSec controls and embeds them into the development pipeline rather than applying them as a one-time audit.

ℹ️ The tools on this page map to when in the lifecycle they run: SAST reads source code, SCA inspects your dependencies, secrets detection scans commits, and DAST attacks the running app. None is sufficient alone — they catch different bugs at different stages. This is the build-time complement to the runtime detection on the SIEM and SOC page.

Secure Coding Practices

Secure coding means writing code that is resistant to attack by default — validating all input, using safe APIs, avoiding dangerous constructs, and applying the principle of least privilege.

OWASP Top 10

The OWASP Top 10 is the authoritative list of the most critical web application security risks, updated approximately every three to four years. The 2021 edition:

Rank Category Description
A01 Broken Access Control Users can act outside their intended permissions
A02 Cryptographic Failures Sensitive data exposed due to weak or missing encryption
A03 Injection Untrusted data sent to an interpreter (SQL, OS, LDAP)
A04 Insecure Design Missing or ineffective security controls by design
A05 Security Misconfiguration Insecure default configs, missing hardening, verbose errors
A06 Vulnerable and Outdated Components Libraries/frameworks with known CVEs
A07 Identification and Authentication Failures Weak authentication, credential stuffing, session fixation
A08 Software and Data Integrity Failures Unsigned updates, insecure deserialization, CI/CD compromise
A09 Security Logging and Monitoring Failures Insufficient logging, no alerting on attacks
A10 Server-Side Request Forgery (SSRF) Server makes requests to attacker-controlled destinations

SQL Injection Prevention

SQL injection remains the most exploited injection type. It occurs when untrusted input is concatenated into a SQL query.

import sqlite3

db = sqlite3.connect('users.db')
cursor = db.cursor()

# VULNERABLE: direct string interpolation
username = "admin' OR '1'='1"
query = f"SELECT * FROM users WHERE username = '{username}'"
cursor.execute(query)   # returns all rows — authentication bypassed

# SAFE: parameterized query (prepared statement)
username = "admin' OR '1'='1"
cursor.execute("SELECT * FROM users WHERE username = ?", (username,))
# Returns nothing — the literal string is treated as data, not SQL

# SAFE with SQLAlchemy ORM
from sqlalchemy import select
from sqlalchemy.orm import Session

with Session(engine) as session:
    user = session.execute(
        select(User).where(User.username == username)
    ).scalar_one_or_none()

Parameterized queries are the only reliable defense. ORMs use parameterized queries internally. Input sanitization (blacklisting quotes, etc.) is not sufficient — use allowlists for any untrusted data that must go into a query.

Cross-Site Scripting (XSS) Prevention

XSS injects malicious scripts into web pages viewed by other users. There are three types: Stored (persisted in database), Reflected (in URL parameters), and DOM-based (client-side).

# Server-side: always HTML-encode output
import html

user_input = "<script>alert('XSS')</script>"
safe_output = html.escape(user_input)
# Produces: &lt;script&gt;alert(&#x27;XSS&#x27;)&lt;/script&gt;

# Flask/Jinja2: auto-escaping is ON by default for .html templates
#    — safe (escaped automatically)
#   — UNSAFE: disables escaping, do not use with untrusted data

# Django templates also auto-escape by default
#    — safe
#   — UNSAFE

Content Security Policy (CSP) is a defense-in-depth HTTP header that restricts which scripts can execute:

Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{random}'; object-src 'none'

Input Validation

Validate all input at the boundary between untrusted and trusted code:

import re
from pathlib import Path

# Allowlist validation (preferred over denylist)
def validate_username(username: str) -> str:
    """Accept only alphanumeric + underscore, 3-32 chars."""
    if not re.fullmatch(r'[a-zA-Z0-9_]{3,32}', username):
        raise ValueError(f"Invalid username: {username!r}")
    return username

# Path traversal prevention
def safe_open(base_dir: str, filename: str):
    """Prevent directory traversal attacks."""
    base = Path(base_dir).resolve()
    target = (base / filename).resolve()
    if not str(target).startswith(str(base)):
        raise PermissionError(f"Path traversal detected: {filename!r}")
    return target.open()

# Example: reject '../../../etc/passwd'
try:
    safe_open('/var/www/uploads', '../../../etc/passwd')
except PermissionError as e:
    print(e)  # Path traversal detected: '../../../etc/passwd'

Secure API Design

REST and gRPC APIs require their own security controls:

from functools import wraps
from flask import Flask, request, jsonify, abort
import jwt
import time

app = Flask(__name__)
SECRET_KEY = "from-vault-not-hardcoded"

def require_jwt(f):
    """Decorator: validate JWT Bearer token on every request."""
    @wraps(f)
    def decorated(*args, **kwargs):
        auth = request.headers.get('Authorization', '')
        if not auth.startswith('Bearer '):
            abort(401, 'Missing Bearer token')
        token = auth[7:]
        try:
            payload = jwt.decode(token, SECRET_KEY, algorithms=['HS256'])
        except jwt.ExpiredSignatureError:
            abort(401, 'Token expired')
        except jwt.InvalidTokenError:
            abort(401, 'Invalid token')
        return f(payload, *args, **kwargs)
    return decorated

@app.route('/api/resource/<int:resource_id>')
@require_jwt
def get_resource(payload, resource_id):
    # Enforce object-level authorization (IDOR prevention)
    user_id = payload['sub']
    resource = db.get_resource(resource_id)
    if resource is None:
        abort(404)
    if resource.owner_id != user_id:
        abort(403, 'Forbidden')     # do not reveal existence
    return jsonify(resource.to_dict())

API security checklist:

Static Application Security Testing (SAST)

SAST analyzes source code without executing it, finding vulnerabilities by examining code patterns.

Bandit (Python)

Bandit is the standard Python SAST tool, part of the PyCQA organization:

pip install bandit

# Scan a directory
bandit -r myapp/

# Report only medium and above
bandit -r myapp/ -ll

# JSON output for CI parsing
bandit -r myapp/ -f json -o bandit-report.json

# Suppress a false positive inline
result = subprocess.run(cmd, shell=False)  # noqa: S603

# Run only specific tests
bandit -r myapp/ -t B301,B302,B303   # only deserialization checks

Bandit test categories relevant to DevSecOps:

Test ID Category Example finding
B101 assert_used assert used for security checks (stripped in optimized bytecode)
B105-108 hardcoded_password Hardcoded credentials in code
B201 flask_debug_true Flask debug mode enabled
B301-303 pickle Insecure deserialization with pickle
B311 random random used instead of secrets for security tokens
B320 xml_bad_element_tree Vulnerable XML parsing (XXE)
B501-506 ssl/tls Weak SSL/TLS settings
B601-605 injection Shell injection risks
import secrets
import random

# BAD: random is not cryptographically secure
token = random.token_hex(32)    # B311: Use of random for security

# GOOD: secrets module is cryptographically secure
token = secrets.token_hex(32)
session_id = secrets.token_urlsafe(32)

Semgrep

Semgrep is a fast, multi-language SAST tool that uses pattern-matching rules. It can be used via CLI or integrated into CI:

pip install semgrep

# Scan with OWASP Top 10 ruleset
semgrep --config p/owasp-top-ten myapp/

# Scan with Python-specific security rules
semgrep --config p/python myapp/

# Scan with multiple rulesets
semgrep --config p/security-audit --config p/secrets myapp/

# Output as SARIF (for GitHub Code Scanning upload)
semgrep --config p/owasp-top-ten --sarif -o semgrep.sarif myapp/
# Python: run Semgrep programmatically and parse results
import subprocess
import json

result = subprocess.run(
    ['semgrep', '--config', 'p/python', '--json', 'myapp/'],
    capture_output=True, text=True
)
findings = json.loads(result.stdout)
for f in findings.get('results', []):
    print(f"{f['path']}:{f['start']['line']}{f['check_id']}: {f['extra']['message']}")

SAST in CI/CD

# GitHub Actions: run Bandit and Semgrep on every push
jobs:
  sast:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Bandit SAST
        run: |
          pip install bandit
          bandit -r src/ -f json -o bandit.json -ll
          # Exit 1 on findings stops the pipeline
        continue-on-error: false

      - name: Semgrep SAST
        # The old returntocorp/semgrep-action is retired — run the CLI directly.
        run: |
          pip install semgrep
          semgrep scan --error \
            --config p/owasp-top-ten \
            --config p/python \
            --config p/secrets

      - name: Upload SARIF to GitHub Security tab
        uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: semgrep.sarif

Dynamic Application Security Testing (DAST)

DAST tests a running application from the outside — the way an attacker would — without access to source code.

OWASP ZAP

OWASP ZAP (Zed Attack Proxy) is the leading open-source DAST tool. It intercepts HTTP traffic and actively probes for vulnerabilities.

# Install ZAP (Docker is the easiest method)
docker pull ghcr.io/zaproxy/zaproxy:stable

# Run a baseline (passive) scan against a running app
docker run --rm ghcr.io/zaproxy/zaproxy:stable \
    zap-baseline.py -t http://localhost:8080 -r zap-report.html

# Run a full active scan (sends attack payloads — use only on test environments)
docker run --rm ghcr.io/zaproxy/zaproxy:stable \
    zap-full-scan.py -t http://localhost:8080 -r zap-full-report.html

# Run an API scan against an OpenAPI spec
docker run --rm ghcr.io/zaproxy/zaproxy:stable \
    zap-api-scan.py -t http://localhost:8080/openapi.json -f openapi \
    -r zap-api-report.html
# Python: control ZAP via its REST API
import time
import requests

ZAP_BASE = 'http://localhost:8080'
TARGET = 'http://testapp:5000'
API_KEY = 'changeme'

# Spider the target
requests.get(f'{ZAP_BASE}/JSON/spider/action/scan/',
             params={'apikey': API_KEY, 'url': TARGET})
time.sleep(10)

# Run active scan
resp = requests.get(f'{ZAP_BASE}/JSON/ascan/action/scan/',
                    params={'apikey': API_KEY, 'url': TARGET})
scan_id = resp.json()['scan']

# Poll until complete
while True:
    progress = requests.get(f'{ZAP_BASE}/JSON/ascan/view/status/',
                            params={'apikey': API_KEY, 'scanId': scan_id}).json()
    if progress['status'] == '100':
        break
    time.sleep(5)

# Retrieve alerts
alerts = requests.get(f'{ZAP_BASE}/JSON/alert/view/alerts/',
                      params={'apikey': API_KEY}).json()
for alert in alerts['alerts']:
    print(f"{alert['risk']}: {alert['alert']} at {alert['url']}")

Nuclei

Nuclei is a fast, template-based vulnerability scanner useful for DAST and CVE checking:

# Install
go install -v github.com/projectdiscovery/nuclei/v3/cmd/nuclei@latest
# Or via prebuilt binary:
curl -s https://api.github.com/repos/projectdiscovery/nuclei/releases/latest \
    | grep browser_download_url | grep linux_amd64 | cut -d'"' -f4 \
    | xargs curl -Lo nuclei.zip && unzip nuclei.zip && mv nuclei /usr/local/bin/

# Update templates
nuclei -update-templates

# Scan a target
nuclei -u https://testapp.example.com

# Use specific template categories
nuclei -u https://testapp.example.com -t cves/ -t exposures/

# Scan with severity filter
nuclei -u https://testapp.example.com -severity critical,high

# Output in JSON for parsing
nuclei -u https://testapp.example.com -json -o nuclei-results.json

DAST in CI/CD

DAST requires a running application, so it runs later in the pipeline than SAST:

jobs:
  dast:
    runs-on: ubuntu-latest
    needs: [deploy-staging]   # requires a running environment
    steps:
      - name: Run ZAP baseline scan
        uses: zaproxy/action-baseline@v0.12.0
        with:
          target: 'https://staging.myapp.example.com'
          fail_action: true
          rules_file_name: '.zap/rules.tsv'   # suppress known false positives

Software Composition Analysis (SCA)

SCA identifies known vulnerabilities (CVEs) in third-party libraries and dependencies.

Trivy

Trivy is the most widely adopted open-source vulnerability scanner. It scans container images, filesystems, Git repositories, Kubernetes clusters, and IaC files.

# Install
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \
    | sh -s -- -b /usr/local/bin

trivy --version

# Scan a container image
trivy image nginx:latest
trivy image --severity CRITICAL,HIGH nginx:latest
trivy image --exit-code 1 --severity CRITICAL nginx:latest   # fail on critical

# Scan a filesystem (Python project)
trivy fs .
trivy fs --severity HIGH,CRITICAL .

# Scan a Git repository
trivy repo https://github.com/myorg/myapp

# Generate SBOM (CycloneDX format)
trivy image --format cyclonedx --output sbom.json nginx:latest

# Scan a running Kubernetes cluster
trivy k8s --report summary cluster

Grype

Grype is an alternative vulnerability scanner from Anchore, focused on container images and filesystems:

# Install
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh \
    | sh -s -- -b /usr/local/bin

grype --version

# Scan an image
grype nginx:latest

# Fail on high+ severity
grype nginx:latest --fail-on high

# Scan a directory
grype dir:.

# Output as JSON
grype nginx:latest -o json > grype-results.json

# Ignore specific CVEs (with justification in .grype.yaml)
# .grype.yaml — project-level Grype configuration
ignore:
  - vulnerability: CVE-2021-44228   # Log4j: not reachable in this image
    reason: "We use log4j-core 2.17.1 (patched)"
  - fix-state: not-fixed            # ignore CVEs with no available fix

Safety (Python dependencies)

pip install safety

# `safety check` was retired (unsupported past June 2024) — use `safety scan`,
# which auto-discovers requirements/Poetry/Pipenv files in the project directory.

# Scan the project (current directory)
safety scan

# JSON output
safety scan --output json

# Use in CI — `scan` exits non-zero when vulnerabilities are found.
# Safety 3.x requires auth: run `safety auth` once, or set SAFETY_API_KEY in CI.
safety scan
# Parse safety output in Python
import subprocess
import json

result = subprocess.run(
    ['safety', 'check', '--json', '-r', 'requirements.txt'],
    capture_output=True, text=True
)
data = json.loads(result.stdout)
for vuln in data.get('vulnerabilities', []):
    print(f"{vuln['package_name']} {vuln['analyzed_version']}: "
          f"{vuln['vulnerability_id']}{vuln['advisory']}")

Secrets Detection

Hardcoded secrets (API keys, passwords, tokens) in source code are one of the most common and most damaging vulnerabilities. Once committed to a repository — even if later deleted — secrets are permanently in the git history unless the entire history is rewritten.

gitleaks

gitleaks scans git repositories for secrets using regular expression rules and entropy analysis:

# Install (pinned version — the tag and the filename must match)
curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz \
    | tar -xz -C /usr/local/bin gitleaks

# Scan the current repo (full history)
gitleaks git . --verbose

# Scan a specific commit range (see caution below)
gitleaks git . --log-opts="HEAD~10..HEAD"

# Scan a directory of files (no git context, working tree only)
gitleaks dir .

# Scan piped input
git diff | gitleaks stdin

# Output report as JSON
gitleaks git . --report-format json --report-path gitleaks-report.json

# Use as a pre-commit hook
# Install: pip install pre-commit
# .pre-commit-config.yaml:
#   - repo: https://github.com/gitleaks/gitleaks
#     rev: v8.30.1
#     hooks:
#       - id: gitleaks

The git vs. dir distinction is the one that matters: dir sees only the working tree, so a credential that was committed and later deleted produces zero findings from dir and a finding from git. Scan history.

⚠️ These subcommands were introduced in gitleaks 8.19, replacing detect --source . (now git), detect --no-git (now dir), and detect --pipe (now stdin). The old forms still execute but are hidden from --help and may eventually be removed — most documentation and tutorials online still use them.

⚠️ A --log-opts range wider than the available history is a silent pass. On a repo with only two commits, --log-opts="HEAD~10..HEAD" reports 0 commits scanned and no leaks found — exit code 0, nothing scanned. The same trap applies to shallow CI clones, which is why secret-scanning jobs need fetch-depth: 0. Always check the commit count in the output, not just the leak count.

truffleHog

truffleHog uses entropy analysis and regular expressions to find secrets, and can scan git history, S3 buckets, and Slack:

# Install
pip install trufflehog3
# Or download the binary:
curl -sSfL https://github.com/trufflesecurity/trufflehog/releases/latest/download/trufflehog_3.88.1_linux_amd64.tar.gz \
    | tar -xz -C /usr/local/bin

# Scan a git repo
trufflehog git https://github.com/myorg/myapp --only-verified

# Scan a local repo
trufflehog git file://. --only-verified

# Scan a single file
trufflehog filesystem ./config.yaml

# JSON output
trufflehog git file://. --json

Pre-commit hooks for secrets prevention

The best time to catch a secret is before it is committed:

# Install pre-commit
pip install pre-commit

# .pre-commit-config.yaml
cat > .pre-commit-config.yaml << 'EOF'
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.21.2
    hooks:
      - id: gitleaks

  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.5.0
    hooks:
      - id: detect-private-key
      - id: detect-aws-credentials
EOF

# Install the hooks
pre-commit install

# Run manually
pre-commit run --all-files

SBOM Generation

A Software Bill of Materials (SBOM) is a machine-readable inventory of all software components in an application — including transitive dependencies. SBOMs are required by US Executive Order 14028 for software sold to the federal government and are an emerging best practice for all software.

SBOM formats

Format Specification Typical use
CycloneDX OWASP Most tooling support; recommended for vulnerability correlation
SPDX Linux Foundation Legal/license compliance focus; ISO standard

Syft — SBOM generation

Syft (from Anchore) generates SBOMs from container images, directories, and archives:

# Install
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh \
    | sh -s -- -b /usr/local/bin

# Generate SBOM for a container image (CycloneDX)
syft nginx:latest -o cyclonedx-json=sbom.json

# Generate SBOM for a Python project directory
syft dir:. -o spdx-json=sbom-spdx.json

# Generate SBOM in multiple formats
syft nginx:latest -o cyclonedx-json -o spdx-json=sbom.spdx.json

# Pipe SBOM into Grype for vulnerability matching
syft nginx:latest -o json | grype

Trivy SBOM generation

Trivy can generate SBOMs as part of its image scan:

# Generate CycloneDX SBOM
trivy image --format cyclonedx --output sbom.cdx.json nginx:latest

# Generate SPDX SBOM
trivy image --format spdx-json --output sbom.spdx.json nginx:latest

Cosign — artifact signing

Cosign (part of the Sigstore project) signs container images and other artifacts so consumers can verify their authenticity and integrity:

# Install
curl -O https://github.com/sigstore/cosign/releases/latest/download/cosign-linux-amd64
mv cosign-linux-amd64 /usr/local/bin/cosign
chmod +x /usr/local/bin/cosign

# Sign an image (keyless — uses OIDC identity, stored in Rekor transparency log)
cosign sign ghcr.io/myorg/myapp:v1.0.0

# Sign with a key pair
cosign generate-key-pair                    # creates cosign.key and cosign.pub
cosign sign --key cosign.key ghcr.io/myorg/myapp:v1.0.0

# Verify a signature
cosign verify --key cosign.pub ghcr.io/myorg/myapp:v1.0.0

# Attach an SBOM to the image
cosign attach sbom --sbom sbom.cdx.json ghcr.io/myorg/myapp:v1.0.0

# Verify the SBOM attachment
cosign verify-attestation --key cosign.pub --type cyclonedx \
    ghcr.io/myorg/myapp:v1.0.0

Worked example — signed image build and verify pipeline:

# GitHub Actions: build, scan, sign, verify
jobs:
  build-and-sign:
    runs-on: ubuntu-latest
    permissions:
      id-token: write     # required for keyless signing
      packages: write
    steps:
      - uses: actions/checkout@v4

      - name: Build image
        run: docker build -t ghcr.io/$:$ .

      - name: Scan with Trivy
        # Pin to a full commit SHA, not a moving ref: trivy-action tags were
        # force-pushed in a March 2026 supply-chain compromise.
        uses: aquasecurity/trivy-action@<commit-sha>   # SHA for a known-good release
        with:
          image-ref: ghcr.io/$:$
          severity: CRITICAL
          exit-code: 1

      - name: Push image
        run: docker push ghcr.io/$:$

      - name: Sign image with cosign (keyless)
        uses: sigstore/cosign-installer@v3
      - run: |
          cosign sign --yes ghcr.io/$:$

Key takeaways

References


Related course pages: Vulnerability Management · Software Supply-Chain Security · DevSecOps Fundamentals

🛠️ Maintenance note: the OWASP Top 10 2025 edition is in release-candidate stage and will reshuffle these categories — update the table when it’s final. Tool pins drift fast: re-verify the gitleaks/truffleHog/Syft version strings in the install commands, and replace the <commit-sha> placeholders on trivy-action with a current known-good commit SHA (its tags were force-pushed in a March 2026 supply-chain compromise). Re-verify each term.