Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions .github/workflows/website.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Build and deploy the documentation site.
#
# Pull requests build the site but do not deploy: a broken build should fail
# review, not production. Only a push to main that touches website/** ships.
#
# The Pages source must be set to "GitHub Actions" in repository settings for
# the deploy step to work. Until it is, the build job still runs and guards
# the site from regressions.

name: Website

on:
workflow_dispatch:
push:
branches: [main]
paths:
- 'website/**'
- '.github/workflows/website.yml'
pull_request:
branches: [main]
paths:
- 'website/**'
- '.github/workflows/website.yml'

permissions:
contents: read

concurrency:
group: pages
cancel-in-progress: false

jobs:
build:
name: Build site
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3

- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: npm
cache-dependency-path: website/package-lock.json

- name: Install dependencies
run: npm ci
working-directory: website

- name: Type-check
run: npm run typecheck
working-directory: website

# docusaurus.config.ts sets onBrokenLinks: 'throw', so a dead internal
# link fails here rather than shipping.
- name: Build
run: npm run build
working-directory: website

- name: Upload Pages artifact
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: website/build

deploy:
name: Deploy to GitHub Pages
needs: build
if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
runs-on: ubuntu-latest
permissions:
contents: read
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy
id: deployment
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
108 changes: 108 additions & 0 deletions tests/test_website_docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""The website must not drift from the repository it documents.

The site reuses content that also lives in the repo (case studies, examples).
Copies rot: someone fixes a number in one place and the other quietly keeps
claiming the old one. These tests fail when that happens.

They are deliberately cheap and offline - no build, no network - so they run in
the normal suite rather than only in the website workflow.
"""

import re
import unittest
from pathlib import Path

ROOT = Path(__file__).parent.parent
WEBSITE = ROOT / "website"
WEBSITE_DOCS = WEBSITE / "docs"


@unittest.skipUnless(WEBSITE.exists(), "website/ is not present")
class TestWebsiteStructure(unittest.TestCase):
def test_every_sidebar_entry_resolves_to_a_file(self):
"""A sidebar id with no file breaks the build; catch it here first."""
sidebar = (WEBSITE / "sidebars.ts").read_text()
ids = re.findall(r"'([a-z0-9][a-z0-9/-]*)'", sidebar)
# Filter out config keys and type names, keeping doc ids.
candidates = [i for i in ids if not i.startswith(("doc", "category"))]
missing = [
doc_id
for doc_id in candidates
if not (WEBSITE_DOCS / f"{doc_id}.md").exists()
and not (WEBSITE_DOCS / f"{doc_id}.mdx").exists()
and not (WEBSITE_DOCS / doc_id / "index.md").exists()
]
self.assertEqual(missing, [], f"sidebar references missing docs: {missing}")

def test_every_doc_has_front_matter(self):
"""Without a title, Docusaurus infers one from the filename."""
for path in WEBSITE_DOCS.rglob("*.md"):
text = path.read_text()
self.assertTrue(
text.startswith("---\n"),
f"{path.relative_to(ROOT)} is missing front matter",
)
self.assertIn(
"title:", text.split("---")[1],
f"{path.relative_to(ROOT)} front matter has no title",
)

def test_no_relative_links_escape_the_website(self):
"""A '../../examples/x' link works in the repo and 404s on the site."""
offenders = []
for path in WEBSITE_DOCS.rglob("*.md"):
for _, link in re.findall(r"\[([^\]]*)\]\(([^)]+)\)", path.read_text()):
if link.startswith("../../") or link.startswith("../examples"):
offenders.append(f"{path.relative_to(ROOT)}: {link}")
self.assertEqual(offenders, [], f"links escape the site root: {offenders}")


@unittest.skipUnless(WEBSITE.exists(), "website/ is not present")
class TestCaseStudiesStayInSync(unittest.TestCase):
"""Case studies exist in both docs/ and website/docs/. The headline
numbers must agree, or one of them is lying to a reader."""

STUDIES = ("node-18", "python-slim", "nginx-alpine")

def test_every_repo_case_study_is_published(self):
for name in self.STUDIES:
self.assertTrue(
(WEBSITE_DOCS / "case-studies" / f"{name}.md").exists(),
f"{name} is in docs/case-studies but not on the site",
)

def test_headline_numbers_match(self):
"""Compare the digits in each table, which is where the claims live."""
for name in self.STUDIES:
repo = (ROOT / "docs" / "case-studies" / f"{name}.md").read_text()
site = (WEBSITE_DOCS / "case-studies" / f"{name}.md").read_text()
repo_numbers = set(re.findall(r"\|\s*([\d,]+(?:\.\d+)?)\s*\|", repo))
site_numbers = set(re.findall(r"\|\s*([\d,]+(?:\.\d+)?)\s*\|", site))
self.assertEqual(
repo_numbers, site_numbers,
f"{name}: repo and site report different numbers "
f"(only in repo: {repo_numbers - site_numbers}; "
f"only on site: {site_numbers - repo_numbers})",
)


@unittest.skipUnless(WEBSITE.exists(), "website/ is not present")
class TestWebsiteIsolation(unittest.TestCase):
"""The scanner must never gain a frontend dependency."""

def test_website_deps_are_not_in_the_package(self):
pyproject = (ROOT / "pyproject.toml").read_text()
for marker in ("docusaurus", "react", "node_modules"):
self.assertNotIn(
marker, pyproject.lower(),
f"'{marker}' leaked into the Python package metadata",
)

def test_website_build_output_is_ignored(self):
gitignore = (WEBSITE / ".gitignore").read_text()
for path in ("/build", "/.docusaurus", "/node_modules"):
self.assertIn(path, gitignore, f"{path} is not gitignored")


if __name__ == "__main__":
unittest.main()
14 changes: 14 additions & 0 deletions website/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Build output and caches
/build
/.docusaurus
/node_modules

# Local env
.env.local
.env.development.local
.env.test.local
.env.production.local

npm-debug.log*
yarn-debug.log*
yarn-error.log*
9 changes: 9 additions & 0 deletions website/.npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# The public npm registry, pinned for this project.
#
# Without this, npm inherits whatever registry the machine is configured for.
# A corporate Artifactory mirror is unreachable from CI and from a contributor
# outside that network, and the failure mode is a confusing ENOTFOUND rather
# than anything that names the cause.
#
# This is a public OWASP project: its dependencies must resolve for anyone.
registry=https://registry.npmjs.org/
72 changes: 72 additions & 0 deletions website/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# DockSec website

The public documentation site for OWASP DockSec, built with
[Docusaurus](https://docusaurus.io/).

Website dependencies live here and nowhere else, so they never become runtime
dependencies of the scanner. A supply-chain security tool should not ship a
frontend toolchain to the people who install it.

## Local development

```bash
cd website
npm install
npm start # http://localhost:3000/DockSec/
```

## Build

```bash
npm run build # output in website/build/ (gitignored)
npm run serve # preview the production build
npm run typecheck # types only, no build
```

## Deployment

`.github/workflows/website.yml` builds on every pull request touching
`website/**` and deploys to GitHub Pages on push to `main`.

The site is served from `https://owasp.github.io/DockSec/`. A vanity redirect
at `owasp.org/DockSec/` can be requested from OWASP infrastructure later - that
needs no rebuild, because the canonical URL does not change. This is how
`owasp.org/cve-lite-cli/` works today.

**Repository setting required:** Pages source must be "GitHub Actions". Until
it is, the build job runs and guards against regressions, but nothing deploys.

## Layout

```
website/
├── docusaurus.config.ts site config, navbar, footer, SEO
├── sidebars.ts docs navigation
├── docs/ content (Markdown)
├── src/
│ ├── pages/index.tsx landing page - composes the sections below
│ ├── components/ one component + CSS module per section
│ └── css/custom.css design tokens; all colours live here
└── static/img/ logo, favicon, social card
```

## Conventions

**Design tokens.** Colours, spacing and radii are custom properties in
`src/css/custom.css`. A hard-coded colour in a component is a bug - change the
token instead, and both themes stay correct.

**Terminal transcripts are real.** The output in `src/components/transcripts.ts`
was captured from actual runs, and each export names the command in a comment.
When output changes, re-run the command and paste the result. A security tool
that mocks up its own results is not one to trust.

**Broken links fail the build.** `onBrokenLinks` is set to `throw`. If a build
fails on a link, fix the link rather than relaxing the setting.

**Adding a page.** Drop a Markdown file in `docs/` with front matter, then add
its id to `sidebars.ts`. No component changes needed.

**Adding a landing-page section.** Add a component and its CSS module under
`src/components/`, then render it in `src/pages/index.tsx`. Sections are
self-contained so they can be reordered or removed in one line.
35 changes: 35 additions & 0 deletions website/docs/case-studies/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
title: Case studies
sidebar_position: 0
---

Real scans of official Docker Hub images, recorded so the numbers can be
checked rather than taken on trust. Every command is copy-pasteable and every
figure below came from running it.

Official images were chosen deliberately: they are maintained by people who
know what they are doing, they are what most teams actually build on, and
publishing their findings names no third party unfavourably. If DockSec finds
something here, the point is not that the maintainers were careless - it is
that a base image is a supply chain you inherit whether or not you look at it.

**Scanned 2026-09-20** with DockSec 2026.9.21, Trivy 0.74.0. Vulnerability
counts move as advisories are published, so expect your numbers to differ;
the shape of the result is what these studies are about.

| Study | Image | Findings | The point |
| --- | --- | --- | --- |
| [1](/docs/case-studies/node-18) | `node:18` | 2,200 | Triage: 9 CVEs deserve attention today, not 2,200 |
| [2](/docs/case-studies/python-slim) | `python:3.12-slim` | 44 | A well-maintained image with nothing you can fix |
| [3](/docs/case-studies/nginx-alpine) | `nginx:1.31.6-alpine` | 0 | What a clean result looks like, and what it does not prove |

## Why these three

They are the three outcomes a user will actually meet, and each one needs a
different response:

- **Overwhelming** (`node:18`) - the case for prioritisation.
- **Unactionable** (`python:3.12-slim`) - findings with no available fix, where
the honest answer is "change the base image or accept the risk".
- **Clean** (`nginx:1.31.6-alpine`) - where the interesting question becomes
what the scan did *not* check.
53 changes: 53 additions & 0 deletions website/docs/case-studies/nginx-alpine.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
---
title: "nginx:alpine - a clean result"
sidebar_position: 3
---

**Zero findings. Here is what that does and does not mean.**

```bash
docksec -i nginx:1.31.6-alpine --image-only --scan-only
```

## Result

| | |
| --- | --- |
| Total findings | 0 |
| CRITICAL / HIGH | 0 |
| Security score | 100 / 100 |

Alpine's package set is small, musl replaces glibc, and the image carries
almost nothing beyond nginx itself. Fewer packages means fewer advisories -
this is the strongest practical argument for a minimal base image, and it is
worth more than any amount of post-hoc hardening.

## What a clean result does not prove

This is the part most tools leave out, and the reason DockSec prints coverage
notes on every run including this one:

- **Not "no vulnerabilities".** It means no *known, published* advisory matches
a package in this image today. Tomorrow's advisory applies to the same bytes.
- **Not "your application is safe".** Nothing here scanned your code, your
dependencies, your Dockerfile or your compose file. A clean base image with
a root-running service and a mounted Docker socket is not a secure
deployment - see
[`examples/compose/docker-compose-insecure.yml`](https://github.com/OWASP/DockSec/tree/main/examples/compose/docker-compose-insecure.yml).
- **Not "reachability was checked".** DockSec does not prove a vulnerable code
path is invoked, and does not claim to.
- **Not a statement about configuration.** Run the Dockerfile and compose
scans for that; they are where most real findings live.

## What to actually do

Pin it and keep it pinned:

```dockerfile
FROM nginx:1.31.6-alpine@sha256:<digest>
```

A tag moves; a digest does not. Then re-scan on a schedule, because the result
above has a shelf life. Use `--baseline` so CI tells you when a new advisory
lands against an image that was clean yesterday - that transition is the
signal worth alerting on.
Loading
Loading