feat(reports): add GET /reports/scans endpoint for defenseur-auto #6
4 changed files with 251 additions and 5 deletions
|
|
@ -4,3 +4,6 @@ PORT=3001
|
||||||
# Buildtime ARG leaks the secret in clear in application_deployment_queues.logs.
|
# Buildtime ARG leaks the secret in clear in application_deployment_queues.logs.
|
||||||
HEALTH_TOKEN=change-me-to-a-strong-secret
|
HEALTH_TOKEN=change-me-to-a-strong-secret
|
||||||
LOGTO_HEALTH_URL=https://auth.lacompagniemaximus.com/oidc/.well-known/openid-configuration
|
LOGTO_HEALTH_URL=https://auth.lacompagniemaximus.com/oidc/.well-known/openid-configuration
|
||||||
|
# Directory served by GET /reports/scans. Bind-mount target on Coolify —
|
||||||
|
# parent /data/defenseurs/ is already mounted (status.json sits next to it).
|
||||||
|
REPORTS_DIR=/data/defenseurs/reports
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ API sante minimaliste pour le VPS. ~127 lignes, Node 22 + HTTP natif.
|
||||||
|
|
||||||
- `GET /health` — CPU, memoire, disque, uptime, logto (`{status, responseTimeMs, error?}`)
|
- `GET /health` — CPU, memoire, disque, uptime, logto (`{status, responseTimeMs, error?}`)
|
||||||
- `GET /defenseurs` — contenu de status.json (rapports defenseurs)
|
- `GET /defenseurs` — contenu de status.json (rapports defenseurs)
|
||||||
|
- `GET /reports/scans?date=YYYY-MM-DD` — agrege les rapports `defenseur-<agent>_<date>*.json` du jour, format `{ date, count, reports: Report[] }`. Filtre `isScanReport` (exclut `defenseur-auto_*.json`). Date validee par regex (path traversal bloque). Consommateur : `defenseur-auto` workstation cron (remplace le pre-rsync SSH). Exemple : `curl -H "Authorization: Bearer $TOKEN" "https://health.lacompagniemaximus.com/reports/scans?date=2026-05-07"`.
|
||||||
|
|
||||||
## Auth
|
## Auth
|
||||||
|
|
||||||
|
|
@ -17,7 +18,8 @@ API sante minimaliste pour le VPS. ~127 lignes, Node 22 + HTTP natif.
|
||||||
|
|
||||||
- Port : `3001` (env `PORT`)
|
- Port : `3001` (env `PORT`)
|
||||||
- `LOGTO_HEALTH_URL` : URL du `.well-known/openid-configuration` (default auth.lacompagniemaximus.com)
|
- `LOGTO_HEALTH_URL` : URL du `.well-known/openid-configuration` (default auth.lacompagniemaximus.com)
|
||||||
- Bind-mount : `/data/defenseurs/status.json` read-only
|
- `REPORTS_DIR` : dossier lu par `/reports/scans` (default `/data/defenseurs/reports`)
|
||||||
|
- Bind-mount : `/data/defenseurs/` (status.json + reports/) read-only
|
||||||
|
|
||||||
## Deploy
|
## Deploy
|
||||||
|
|
||||||
|
|
|
||||||
81
index.js
81
index.js
|
|
@ -1,15 +1,18 @@
|
||||||
const http = require("node:http");
|
const http = require("node:http");
|
||||||
const os = require("node:os");
|
const os = require("node:os");
|
||||||
const { execSync } = require("node:child_process");
|
const { execSync } = require("node:child_process");
|
||||||
const { readFileSync } = require("node:fs");
|
const { readFileSync, readdirSync, existsSync } = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
const { setTimeout: delay } = require("node:timers/promises");
|
const { setTimeout: delay } = require("node:timers/promises");
|
||||||
|
|
||||||
const PORT = parseInt(process.env.PORT || "3001", 10);
|
const PORT = parseInt(process.env.PORT || "3001", 10);
|
||||||
const TOKEN = process.env.HEALTH_TOKEN;
|
const TOKEN = process.env.HEALTH_TOKEN;
|
||||||
|
const REPORTS_DIR = process.env.REPORTS_DIR || "/data/defenseurs/reports";
|
||||||
const LOGTO_HEALTH_URL =
|
const LOGTO_HEALTH_URL =
|
||||||
process.env.LOGTO_HEALTH_URL ||
|
process.env.LOGTO_HEALTH_URL ||
|
||||||
"https://auth.lacompagniemaximus.com/oidc/.well-known/openid-configuration";
|
"https://auth.lacompagniemaximus.com/oidc/.well-known/openid-configuration";
|
||||||
const LOGTO_TIMEOUT_MS = 3000;
|
const LOGTO_TIMEOUT_MS = 3000;
|
||||||
|
const SCAN_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
if (!TOKEN) {
|
if (!TOKEN) {
|
||||||
console.warn("WARNING: HEALTH_TOKEN is not set. All requests will be rejected (fail-closed).");
|
console.warn("WARNING: HEALTH_TOKEN is not set. All requests will be rejected (fail-closed).");
|
||||||
|
|
@ -78,6 +81,50 @@ function getDisk() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reproduce the isScanReport guard from defenseurs/src/report.ts. The
|
||||||
|
// defenseur-auto run report has shape { actions[], skipped[] } with no
|
||||||
|
// findings[] — it must be filtered out so it never reaches the auto pipeline
|
||||||
|
// (which expects scan-shaped reports only).
|
||||||
|
function isScanReport(value) {
|
||||||
|
return (
|
||||||
|
typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
|
Array.isArray(value.findings) &&
|
||||||
|
typeof value.agent === "string" &&
|
||||||
|
typeof value.timestamp === "string"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read all `defenseur-<agent>_<date>*.json` files under REPORTS_DIR for the
|
||||||
|
// given UTC date. The scan reports use an ISO timestamp with `:` and `.`
|
||||||
|
// rewritten as `-` in the filename (e.g. defenseur-booking_2026-05-06T05-30-11-249Z.json).
|
||||||
|
// We match `_<date>` then re-confirm via parsed.timestamp.startsWith(date).
|
||||||
|
function readScanReportsForDate(date) {
|
||||||
|
const out = [];
|
||||||
|
if (!existsSync(REPORTS_DIR)) return out;
|
||||||
|
|
||||||
|
const files = readdirSync(REPORTS_DIR).filter(
|
||||||
|
(f) => f.startsWith("defenseur-") && f.includes(`_${date}`) && f.endsWith(".json"),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(path.join(REPORTS_DIR, file), "utf-8");
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!isScanReport(parsed)) continue;
|
||||||
|
if (!parsed.timestamp.startsWith(date)) continue;
|
||||||
|
out.push(parsed);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[reports/scans] failed to parse ${file}:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stable sort by timestamp asc — same convention as readReports() in
|
||||||
|
// defenseurs/src/report.ts.
|
||||||
|
out.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
async function getHealth() {
|
async function getHealth() {
|
||||||
const cpus = os.cpus();
|
const cpus = os.cpus();
|
||||||
const totalMem = os.totalmem();
|
const totalMem = os.totalmem();
|
||||||
|
|
@ -113,8 +160,13 @@ async function getHealth() {
|
||||||
const server = http.createServer(async (req, res) => {
|
const server = http.createServer(async (req, res) => {
|
||||||
res.setHeader("Content-Type", "application/json");
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
|
||||||
const validRoutes = ["/health", "/defenseurs"];
|
// Parse the URL so /reports/scans can carry a `?date=` query string. The
|
||||||
if (req.method !== "GET" || !validRoutes.includes(req.url)) {
|
// placeholder host is required because URL() needs an absolute URL.
|
||||||
|
const parsedUrl = new URL(req.url, "http://localhost");
|
||||||
|
const pathname = parsedUrl.pathname;
|
||||||
|
|
||||||
|
const validRoutes = ["/health", "/defenseurs", "/reports/scans"];
|
||||||
|
if (req.method !== "GET" || !validRoutes.includes(pathname)) {
|
||||||
res.writeHead(404);
|
res.writeHead(404);
|
||||||
res.end(JSON.stringify({ error: "Not found" }));
|
res.end(JSON.stringify({ error: "Not found" }));
|
||||||
return;
|
return;
|
||||||
|
|
@ -133,7 +185,7 @@ const server = http.createServer(async (req, res) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.url === "/defenseurs") {
|
if (pathname === "/defenseurs") {
|
||||||
const statusPath = process.env.DEFENSEURS_STATUS_PATH || "/data/defenseurs/status.json";
|
const statusPath = process.env.DEFENSEURS_STATUS_PATH || "/data/defenseurs/status.json";
|
||||||
try {
|
try {
|
||||||
const status = readFileSync(statusPath, "utf-8");
|
const status = readFileSync(statusPath, "utf-8");
|
||||||
|
|
@ -146,6 +198,27 @@ const server = http.createServer(async (req, res) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pathname === "/reports/scans") {
|
||||||
|
const date = parsedUrl.searchParams.get("date");
|
||||||
|
// Regex short-circuit before any filesystem access — blocks path
|
||||||
|
// traversal (`../../etc/passwd` -> 400) and bogus inputs.
|
||||||
|
if (!date || !SCAN_DATE_RE.test(date)) {
|
||||||
|
res.writeHead(400);
|
||||||
|
res.end(JSON.stringify({ error: "Bad request: date=YYYY-MM-DD required" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const reports = readScanReportsForDate(date);
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end(JSON.stringify({ date, count: reports.length, reports }));
|
||||||
|
} catch (err) {
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(JSON.stringify({ error: "Internal error", message: err.message }));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await getHealth();
|
const data = await getHealth();
|
||||||
res.writeHead(200);
|
res.writeHead(200);
|
||||||
|
|
|
||||||
168
test-curl.sh
Executable file
168
test-curl.sh
Executable file
|
|
@ -0,0 +1,168 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# test-curl.sh — manual smoke test for vps-health-api endpoints.
|
||||||
|
#
|
||||||
|
# Spins up the server against a temporary REPORTS_DIR populated with
|
||||||
|
# scan/run-report fixtures, then runs curl against each endpoint and
|
||||||
|
# checks status codes + payload shape. No test runner installed — this
|
||||||
|
# script is the authoritative regression suite for the GET /reports/scans
|
||||||
|
# endpoint until vitest/jest is added.
|
||||||
|
#
|
||||||
|
# Usage :
|
||||||
|
# bash test-curl.sh
|
||||||
|
#
|
||||||
|
# Exit 0 if all 10 cases pass, exit 1 on first failure (fail-fast).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE_URL="${BASE_URL:-http://localhost:3099}"
|
||||||
|
TOKEN="${TOKEN:-test-token-123}"
|
||||||
|
TMP_DIR="$(mktemp -d -t vps-health-api.XXXXXX)"
|
||||||
|
trap 'rm -rf "$TMP_DIR"; kill "$SERVER_PID" 2>/dev/null || true' EXIT
|
||||||
|
|
||||||
|
# Fixtures :
|
||||||
|
# - 3 scan reports on 2026-05-07 (booking, simpl-liste, maximus)
|
||||||
|
# - 1 defenseur-auto run report on 2026-05-07 (must be filtered out)
|
||||||
|
# - 1 booking scan report on 2026-05-06 (must be excluded by date filter)
|
||||||
|
mkdir -p "$TMP_DIR/reports"
|
||||||
|
|
||||||
|
cat > "$TMP_DIR/reports/defenseur-booking_2026-05-07T05-30-11-249Z.json" <<'JSON'
|
||||||
|
{
|
||||||
|
"agent": "defenseur-booking",
|
||||||
|
"timestamp": "2026-05-07T05:30:11.249Z",
|
||||||
|
"project": "la-suite-booking",
|
||||||
|
"checksRun": 16,
|
||||||
|
"checksPassed": 14,
|
||||||
|
"findings": []
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
|
||||||
|
cat > "$TMP_DIR/reports/defenseur-simpl-liste_2026-05-07T05-32-04-512Z.json" <<'JSON'
|
||||||
|
{
|
||||||
|
"agent": "defenseur-simpl-liste",
|
||||||
|
"timestamp": "2026-05-07T05:32:04.512Z",
|
||||||
|
"project": "simpl-liste",
|
||||||
|
"checksRun": 12,
|
||||||
|
"checksPassed": 11,
|
||||||
|
"findings": []
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
|
||||||
|
cat > "$TMP_DIR/reports/defenseur-maximus_2026-05-07T05-00-12-100Z.json" <<'JSON'
|
||||||
|
{
|
||||||
|
"agent": "defenseur-maximus",
|
||||||
|
"timestamp": "2026-05-07T05:00:12.100Z",
|
||||||
|
"project": "la-compagnie-maximus",
|
||||||
|
"checksRun": 8,
|
||||||
|
"checksPassed": 8,
|
||||||
|
"findings": []
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
|
||||||
|
cat > "$TMP_DIR/reports/defenseur-auto_2026-05-07.json" <<'JSON'
|
||||||
|
{
|
||||||
|
"agent": "defenseur-auto",
|
||||||
|
"timestamp": "2026-05-07T07:00:00.000Z",
|
||||||
|
"status": "ok",
|
||||||
|
"actions": [],
|
||||||
|
"skipped": [],
|
||||||
|
"totalCostUsd": 0
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
|
||||||
|
cat > "$TMP_DIR/reports/defenseur-booking_2026-05-06T05-30-00-000Z.json" <<'JSON'
|
||||||
|
{
|
||||||
|
"agent": "defenseur-booking",
|
||||||
|
"timestamp": "2026-05-06T05:30:00.000Z",
|
||||||
|
"project": "la-suite-booking",
|
||||||
|
"checksRun": 16,
|
||||||
|
"checksPassed": 16,
|
||||||
|
"findings": []
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
|
||||||
|
# Boot the server with the temp REPORTS_DIR.
|
||||||
|
PORT=3099 \
|
||||||
|
HEALTH_TOKEN="$TOKEN" \
|
||||||
|
REPORTS_DIR="$TMP_DIR/reports" \
|
||||||
|
LOGTO_HEALTH_URL="http://127.0.0.1:1/never" \
|
||||||
|
node "$(dirname "$0")/index.js" >/dev/null 2>&1 &
|
||||||
|
SERVER_PID=$!
|
||||||
|
|
||||||
|
# Wait for the server to be ready.
|
||||||
|
for _ in {1..50}; do
|
||||||
|
if curl -s -o /dev/null "$BASE_URL/health" 2>/dev/null; then break; fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
fail() {
|
||||||
|
echo "FAIL: $1"
|
||||||
|
FAIL=$((FAIL+1))
|
||||||
|
}
|
||||||
|
pass() {
|
||||||
|
echo "PASS: $1"
|
||||||
|
PASS=$((PASS+1))
|
||||||
|
}
|
||||||
|
|
||||||
|
# Case 1 : no auth -> 401
|
||||||
|
code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE_URL/reports/scans?date=2026-05-07")
|
||||||
|
[[ "$code" == "401" ]] && pass "no-auth -> 401" || fail "no-auth -> got $code"
|
||||||
|
|
||||||
|
# Case 2 : wrong token -> 401
|
||||||
|
code=$(curl -s -o /dev/null -w '%{http_code}' \
|
||||||
|
-H "Authorization: Bearer wrong" "$BASE_URL/reports/scans?date=2026-05-07")
|
||||||
|
[[ "$code" == "401" ]] && pass "wrong-token -> 401" || fail "wrong-token -> got $code"
|
||||||
|
|
||||||
|
# Case 3 : valid token + date 2026-05-07 -> 200, count=3, sorted asc, no auto report
|
||||||
|
body=$(curl -s -H "Authorization: Bearer $TOKEN" \
|
||||||
|
"$BASE_URL/reports/scans?date=2026-05-07")
|
||||||
|
count=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).count);});')
|
||||||
|
agents=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).reports.map(r=>r.agent).join(","));});')
|
||||||
|
[[ "$count" == "3" ]] && pass "valid date 2026-05-07 -> count=3" || fail "valid date 2026-05-07 -> count=$count"
|
||||||
|
[[ "$agents" == "defenseur-maximus,defenseur-booking,defenseur-simpl-liste" ]] \
|
||||||
|
&& pass "sort by timestamp asc + auto filtered" \
|
||||||
|
|| fail "sort/filter mismatch -> $agents"
|
||||||
|
|
||||||
|
# Case 4 : invalid date format -> 400
|
||||||
|
code=$(curl -s -o /dev/null -w '%{http_code}' \
|
||||||
|
-H "Authorization: Bearer $TOKEN" "$BASE_URL/reports/scans?date=hello")
|
||||||
|
[[ "$code" == "400" ]] && pass "invalid date -> 400" || fail "invalid date -> got $code"
|
||||||
|
|
||||||
|
# Case 5 : missing date param -> 400
|
||||||
|
code=$(curl -s -o /dev/null -w '%{http_code}' \
|
||||||
|
-H "Authorization: Bearer $TOKEN" "$BASE_URL/reports/scans")
|
||||||
|
[[ "$code" == "400" ]] && pass "missing date -> 400" || fail "missing date -> got $code"
|
||||||
|
|
||||||
|
# Case 6 : valid token + unknown date (no fixtures) -> 200 count=0
|
||||||
|
body=$(curl -s -H "Authorization: Bearer $TOKEN" \
|
||||||
|
"$BASE_URL/reports/scans?date=2025-01-01")
|
||||||
|
count=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).count);});')
|
||||||
|
[[ "$count" == "0" ]] && pass "unknown date -> count=0" || fail "unknown date -> count=$count"
|
||||||
|
|
||||||
|
# Case 7 : valid token + date 2026-05-06 -> 200 count=1
|
||||||
|
body=$(curl -s -H "Authorization: Bearer $TOKEN" \
|
||||||
|
"$BASE_URL/reports/scans?date=2026-05-06")
|
||||||
|
count=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).count);});')
|
||||||
|
[[ "$count" == "1" ]] && pass "date 2026-05-06 -> count=1" || fail "date 2026-05-06 -> count=$count"
|
||||||
|
|
||||||
|
# Case 8 : path traversal -> 400 (regex blocks)
|
||||||
|
code=$(curl -s -o /dev/null -w '%{http_code}' \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
"$BASE_URL/reports/scans?date=../../../etc/passwd")
|
||||||
|
[[ "$code" == "400" ]] && pass "path traversal -> 400" || fail "path traversal -> got $code"
|
||||||
|
|
||||||
|
# Case 9 : POST -> 404 (GET-only)
|
||||||
|
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
"$BASE_URL/reports/scans?date=2026-05-07")
|
||||||
|
[[ "$code" == "404" ]] && pass "POST -> 404" || fail "POST -> got $code"
|
||||||
|
|
||||||
|
# Case 10 : wrong path -> 404
|
||||||
|
code=$(curl -s -o /dev/null -w '%{http_code}' \
|
||||||
|
-H "Authorization: Bearer $TOKEN" "$BASE_URL/reports/nope")
|
||||||
|
[[ "$code" == "404" ]] && pass "wrong path -> 404" || fail "wrong path -> got $code"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== Results: $PASS passed, $FAIL failed ==="
|
||||||
|
[[ "$FAIL" == "0" ]] || exit 1
|
||||||
Loading…
Reference in a new issue