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
52 changes: 52 additions & 0 deletions .github/tests/ci-verify-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ interface LefthookDefinition {
const workflowPath = fileURLToPath(new URL('../workflows/ci-verify.yml', import.meta.url));
const lefthookPath = fileURLToPath(new URL('../../lefthook.yml', import.meta.url));
const processorPath = fileURLToPath(new URL('../../test/load-test.processor.cjs', import.meta.url));
const secretScanScriptPath = fileURLToPath(
new URL('../../scripts/scan-secrets.sh', import.meta.url),
);
const gitleaksConfigPath = fileURLToPath(new URL('../../.gitleaks.toml', import.meta.url));
const gitleaksIgnorePath = fileURLToPath(new URL('../../.gitleaksignore', import.meta.url));
const emojiPrefix = /^\p{Extended_Pictographic}/u;
const workflowTestsCommand = 'npm run test:workflows';
const loadWorkflow = loadWorkflowFrom.bind(undefined, workflowPath);
Expand Down Expand Up @@ -78,6 +83,53 @@ test('workflow tests are wired outside the app coverage suite', () => {
});
});

test('secret scanning gates full history and the tracked working tree', () => {
const workflow = loadWorkflow();
const job = workflow.jobs?.secrets;

expect(job).toMatchObject({
name: '🔑 Security: Secrets',
needs: ['zizmor'],
'runs-on': 'ubuntu-latest',
'timeout-minutes': 10,
});
expect(getWorkflowStep('secrets', 'Checkout')).toMatchObject({
with: {
'fetch-depth': 0,
'persist-credentials': false,
},
});
expect(getWorkflowStep('secrets', 'Install Gitleaks')).toMatchObject({
env: {
GITLEAKS_VERSION: '8.30.1',
GITLEAKS_LINUX_X64_SHA256: '551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb',
},
});
expect(getWorkflowStep('secrets', 'Scan secrets')).toMatchObject({
run: 'scripts/scan-secrets.sh',
});

const scanScript = readFileSync(secretScanScriptPath, 'utf8');
expect(scanScript).toContain('gitleaks git');
expect(scanScript).toContain('gitleaks dir');
expect(scanScript).toContain('git -C "${repo_root}" ls-files -z');
expect(scanScript).toContain('--redact');

const gitleaksConfig = readFileSync(gitleaksConfigPath, 'utf8');
expect(gitleaksConfig).toContain('useDefault = true');
expect(gitleaksConfig).not.toContain('paths =');

const baselineEntries = readFileSync(gitleaksIgnorePath, 'utf8').trim().split('\n');
expect(baselineEntries.length).toBeGreaterThan(400);
expect(
baselineEntries.every((entry) =>
/^(?:[0-9a-f]{40}:)?[^:]+:(?:generic-api-key|private-key|curl-auth-header|telegram-bot-api-token):\d+$/u.test(
entry,
),
),
).toBe(true);
});

test('ci-verify can dispatch the complete release-candidate matrix manually', () => {
const workflow = loadWorkflow();

Expand Down
41 changes: 41 additions & 0 deletions .github/workflows/ci-verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,47 @@ jobs:
advanced-security: true
token: ${{ github.token }}

secrets:
name: "🔑 Security: Secrets"
needs: [zizmor]
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read

steps:
- name: Harden Runner
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit

- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
persist-credentials: false

- name: Install Gitleaks
env:
GITLEAKS_VERSION: '8.30.1'
GITLEAKS_LINUX_X64_SHA256: '551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb'
run: |
set -euo pipefail

archive="${RUNNER_TEMP}/gitleaks.tar.gz"
install_dir="${RUNNER_TEMP}/gitleaks-bin"
mkdir -p "${install_dir}"
curl --proto '=https' --tlsv1.2 --retry 3 --fail --silent --show-error --location \
"https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
--output "${archive}"
printf '%s %s\n' "${GITLEAKS_LINUX_X64_SHA256}" "${archive}" | sha256sum --check --strict
tar -xzf "${archive}" -C "${install_dir}" gitleaks
"${install_dir}/gitleaks" version
printf '%s\n' "${install_dir}" >> "${GITHUB_PATH}"

- name: Scan secrets
run: scripts/scan-secrets.sh

changes:
name: "🔎 Changes: Path Filter"
runs-on: ubuntu-latest
Expand Down
4 changes: 4 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
title = "Drydock Gitleaks configuration"

[extend]
useDefault = true
427 changes: 427 additions & 0 deletions .gitleaksignore

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Security

- **Anonymous access now fails closed on upgrades, not just fresh installs.** An instance with no authentication configured — or with anonymous auth enabled but unconfirmed — refuses to start instead of logging a warning and serving an open dashboard. If you run an intentionally open instance, set `DD_ANONYMOUS_AUTH_CONFIRM=true`; otherwise configure `DD_AUTH_BASIC_<name>_USER`/`_HASH` before upgrading.

- **HTTP notification trigger hardened against SSRF.** Hostnames are re-resolved on every request through a guarded DNS lookup that blocks cloud metadata and link-local targets (override with `allowmetadata=true`), and redirects can no longer escape into blocked address space via cross-host or DNS-rebinding hops.

- **WebSocket upgrades validate the complete origin.** Origin checks now compare full scheme/host/port origins — honoring `X-Forwarded-Proto`/`X-Forwarded-Host` only when trust proxy is enabled — instead of host-substring matching, closing WebSocket CSRF gaps behind reverse proxies.

- **Session cookie renamed to `drydock.sid`.** Replaces Express's default `connect.sid`, so drydock sessions no longer collide with (or are fingerprintable as) other Express apps behind the same host. Everyone is signed out once on upgrade.

- **Persistent store files are owner-only.** `/store` is created `0700`, `dd.json` is kept `0600`, and the container entrypoint and store process set a `077` umask so LokiJS autosave replacement files never widen permissions.

- **Icon CDN sources are pinned to exact upstream revisions** (dashboard-icons, selfh.st icons, simple-icons) instead of floating tags, so an upstream push can't silently change what the dashboard serves.

## [1.6.0-rc.2] — 2026-07-18

### Added
Expand Down
1 change: 1 addition & 0 deletions Docker.entrypoint.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#!/usr/bin/env bash
set -eo pipefail
umask 077

require_insecure_root_ack() {
if [ "${DD_ALLOW_INSECURE_ROOT}" != "true" ]; then
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ RUN apk add --no-cache \
tzdata=2026c-r0 \
&& apk add --no-cache cosign=3.0.6-r1 \
&& apk upgrade --no-cache zlib libcrypto3 libssl3 libexpat \
&& mkdir /store && chown node:node /store
&& mkdir -m 0700 /store && chown node:node /store

# Build stage for healthcheck binary (~65KB static binary)
FROM alpine:3.24@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b AS healthcheck-build
Expand Down
15 changes: 8 additions & 7 deletions app/api/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1088,6 +1088,7 @@ describe('Auth Router', () => {
auth.init(app);

const sessionConfig = (session as unknown as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(sessionConfig.name).toBe('drydock.sid');
expect(sessionConfig.cookie).toEqual(
expect.objectContaining({
httpOnly: true,
Expand Down Expand Up @@ -3610,7 +3611,7 @@ describe('Auth Router', () => {
const req = {
method: 'POST',
get: vi.fn((header: string) => {
if (header === 'cookie') return 'connect.sid=abc';
if (header === 'cookie') return 'drydock.sid=abc';
if (header === 'sec-fetch-site') return 'cross-site';
return undefined;
}),
Expand All @@ -3631,7 +3632,7 @@ describe('Auth Router', () => {
const req = {
method: 'POST',
get: vi.fn((header: string) => {
if (header === 'cookie') return 'connect.sid=abc';
if (header === 'cookie') return 'drydock.sid=abc';
if (header === 'sec-fetch-site') return undefined;
if (header === 'origin') return 'https://attacker.example.com';
if (header === 'x-forwarded-proto') return undefined;
Expand All @@ -3657,7 +3658,7 @@ describe('Auth Router', () => {
const req = {
method: 'POST',
get: vi.fn((header: string) => {
if (header === 'cookie') return 'connect.sid=abc';
if (header === 'cookie') return 'drydock.sid=abc';
if (header === 'sec-fetch-site') return undefined;
if (header === 'origin') return 'https://drydock.example.com';
if (header === 'x-forwarded-proto') return undefined;
Expand All @@ -3682,7 +3683,7 @@ describe('Auth Router', () => {
const req = {
method: 'POST',
get: vi.fn((header: string) => {
if (header === 'cookie') return 'connect.sid=abc';
if (header === 'cookie') return 'drydock.sid=abc';
if (header === 'sec-fetch-site') return 'same-origin';
if (header === 'origin') return 'https://drydock.example.com';
if (header === 'x-forwarded-proto') return undefined;
Expand All @@ -3707,7 +3708,7 @@ describe('Auth Router', () => {
const req = {
method: 'POST',
get: vi.fn((header: string) => {
if (header === 'cookie') return 'connect.sid=abc';
if (header === 'cookie') return 'drydock.sid=abc';
if (header === 'sec-fetch-site') return 'cross-site';
return undefined;
}),
Expand All @@ -3728,7 +3729,7 @@ describe('Auth Router', () => {
const req = {
method: 'POST',
get: vi.fn((header: string) => {
if (header === 'cookie') return 'connect.sid=abc';
if (header === 'cookie') return 'drydock.sid=abc';
if (header === 'sec-fetch-site') return undefined;
if (header === 'origin') return 'http://drydock.local';
if (header === 'x-forwarded-proto') return undefined;
Expand All @@ -3753,7 +3754,7 @@ describe('Auth Router', () => {
const req = {
method: 'GET',
get: vi.fn((header: string) => {
if (header === 'cookie') return 'connect.sid=abc';
if (header === 'cookie') return 'drydock.sid=abc';
if (header === 'sec-fetch-site') return 'cross-site';
return undefined;
}),
Expand Down
2 changes: 2 additions & 0 deletions app/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
isIdentityAwareRateLimitKeyingEnabled,
isRequestAuthenticated,
} from './rate-limit-key.js';
import { SESSION_COOKIE_NAME } from './session-cookie.js';

const LokiStore = ConnectLoki(session);
const router = express.Router();
Expand Down Expand Up @@ -369,6 +370,7 @@ export function init(app: Application): void {

// Init express session
sessionMiddleware = session({
name: SESSION_COOKIE_NAME,
store: new LokiStore({
path: `${store.getConfiguration().path}/${store.getConfiguration().file}`,
// Keep store retention >= longest auth cookie lifespan (remember-me).
Expand Down
2 changes: 2 additions & 0 deletions app/api/container/log-stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,7 @@ describe('api/container/log-stream', () => {
origin: 'https://drydock.example.com',
host: '10.0.0.1:3000',
'x-forwarded-host': 'drydock.example.com',
'x-forwarded-proto': 'https',
},
socket: { remoteAddress: '127.0.0.1' },
} as any,
Expand Down Expand Up @@ -445,6 +446,7 @@ describe('api/container/log-stream', () => {
origin: 'https://drydock.example.com',
host: '10.0.0.1:3000',
'x-forwarded-host': 'drydock.example.com, other.example.com',
'x-forwarded-proto': 'https, http',
},
socket: { remoteAddress: '127.0.0.1' },
} as any,
Expand Down
Loading