mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-12 19:55:14 +02:00
Prerelease cleanup (#46)
* feat: Add const_bound_vars tracking to prevent false positives in ownership checks
* feat: Introduce field interner and typed bounded vars for enhanced type tracking
* feat: Add typed_call_receivers and typed_bounded_dto_fields for enhanced type tracking
* feat: Centralize method name extraction with bare_method_name helper
* feat: Implement Phase-6 hierarchy fan-out for runtime virtual dispatch
* feat: Enhance C++ taint tracking with additional container operations and inline method resolution
* feat: Introduce field-sensitive points-to analysis for enhanced resource tracking
* feat: Implement Pointer-Phase 6 subscript handling for enhanced container analysis
* test: Add comprehensive tests for JavaScript control flow constructs and lattice operations
* docs: Update advanced analysis documentation with field-sensitive points-to and hierarchy fan-out details
* test: Add comprehensive tests for lattice algebra laws and SSA edge cases
* feat: Add destructured session user handling and safe user ID access patterns
* feat: Implement row-population reverse-walk for enhanced authorization checks
* feat: Enhance authorization checks with local alias chain for self-actor types
* feat: Introduce ActiveRecord query safety checks and enhance snippet extraction
* feat: Implement chained method call inner-gate rebinding for SSRF prevention
* feat: Add observability and error modules, enhance debug functionality, and implement theme context
* feat: Remove Auth Analysis page and update navigation to redirect to Explorer
* feat: Optimize SSA lowering by sharing results between taint engine and artifact extractor
* feat: Optimize SSA lowering by sharing results between taint engine and artifact extractor
* feat: Reset path-safe-suppressed spans before lowering to maintain analysis integrity
* fix(ssa): ungate debug_assert_bfs_ordering for release-tests build
The helper at src/ssa/lower.rs was gated `#[cfg(debug_assertions)]` while
the unit test at the bottom of the file was gated only `#[cfg(test)]`.
Since `cfg(test)` is set in release builds with `--tests` but
`cfg(debug_assertions)` is not, `cargo build --release --tests` failed
with E0425. Removing the gate fixes the build; the body is `debug_assert!`
only, so the helper is free in release. Also drop the gate at the call
site to avoid a `dead_code` warning when the lib is built without
`--tests`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(closure-capture): flip JS/TS fixtures to required-finding
The JS and TS closure-capture fixtures pinned the old broken behaviour
via `forbidden_findings: [{ "id_prefix": "taint-" }]`. The engine now
correctly traces taint through the closure boundary (env source captured
by an arrow function, sunk via `child_process.exec` inside the body), so
the formerly-forbidden finding is a true positive.
Match the Python sibling's shape — `required_findings` with
`id_prefix` + `min_count` plus a small `noise_budget` — and rewrite the
companion READMEs and the phase8_fragility_tests doc-comments from
"known gap" to "regression guard".
Verified:
- cargo test --release --test phase8_fragility_tests → 8/8 pass
- cargo test --release --lib bfs_assertion → pass
- corpus benchmark F1 = 0.9976 (TP=205, FP=1, FN=0) — unchanged
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: Add OWASP mapping and baseline mutation hooks for enhanced security analysis
* feat: Introduce health module and enhance health score computation with calibration tests
* feat: Add expectations configuration and cleanup .gitignore for log files
* feat: Implement theme selection and enhance settings panel for triage sync
* feat: Suppress false positives for strcpy calls with literal sources in AST
* feat: Update analyse_function_ssa to return body CFG for accurate analysis
* feat: Add bug report and feature request templates for improved issue tracking
* feat: removed dev scripts
* feat: update README.md for clarity and consistency in fixture descriptions
* feat: removed dev docs
* feat: clean up error handling and UI elements for improved user experience
* feat: adjust button sizes in HeaderBar for better UI consistency
* feat: enhance taint analysis with additional context for sanitizer and taint findings
* cargo fmt
* prettier
* refactor: simplify conditional checks and improve code readability in AST and screenshot capture scripts
* feat: add script to frame PNG screenshots with brand gradient
* feat: add fuzzing support with new targets and CI workflows
* refactor: streamline match expressions and improve formatting in CLI and output handling
* feat: enhance configuration display with detailed output options
* feat: stage demo configuration for improved CLI screenshot output
* feat: expose merge_configs function for user-configurable settings
* refactor: simplify code structure and improve readability in config handling
* refactor: improve descriptions for vulnerability patterns in various languages
* feat: update MIT License section with additional usage details and copyright information
* feat: update screenshots
* refactor: update build process and paths for frontend assets
* feat: add cross-file taint fuzzing target and supporting dictionary
* refactor: clean up formatting and comments in fuzz configuration and example files
* refactor: remove outdated comments and clean up CI configuration files
* chore: update changelog dates and improve formatting in documentation
* refactor: update Cargo.toml and CI configuration for improved packaging and build process
* refactor: enhance quote-stripping logic to prevent panics and add regression tests
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
79c29b394d
commit
82f18184b1
348 changed files with 48731 additions and 2925 deletions
|
|
@ -1,11 +1,14 @@
|
|||
# scripts
|
||||
|
||||
Local helpers to run repo-wide tasks without hopping between `./` and `./frontend`.
|
||||
Local helpers for repo-wide checks and a couple of one-off tools.
|
||||
|
||||
| Script | What it does |
|
||||
| ------------ | ----------------------------------------------------------------------- |
|
||||
| `fix.sh` | Apply all auto-fixes (clippy, fmt, eslint, prettier), then run tests. |
|
||||
| `check.sh` | Verify only (no fixes). Mirrors the GitHub Actions CI workflow. |
|
||||
| Script | What it does |
|
||||
| ------------------------ | --------------------------------------------------------------------------------------------- |
|
||||
| `fix.sh` | Apply all auto-fixes (clippy, fmt, eslint, prettier), then run tests. |
|
||||
| `check.sh` | Verify only (no fixes). Mirrors the GitHub Actions CI workflow. |
|
||||
| `cached-cargo-test.sh` | Wrap `cargo test` with a source-hash cache; concurrent invocations of the same args share one run. |
|
||||
| `capture-screenshots.mjs`| Capture the README stills and demo GIF from a running `nyx serve`. Needs Playwright and ffmpeg. |
|
||||
| `frame-screenshots.py` | Wrap a PNG in the brand purple gradient. Called by `capture-screenshots.mjs` as its final phase, but can be run standalone. |
|
||||
|
||||
Fixers stream their output (so you can see what changed); tests run quietly and
|
||||
only show output if they fail. Both scripts print a green/red summary at the end
|
||||
|
|
@ -25,3 +28,73 @@ and exit non-zero if any step failed.
|
|||
|
||||
Scripts can be run from any directory; they resolve the repo root from their
|
||||
own location.
|
||||
|
||||
## Cached cargo test
|
||||
|
||||
Wraps `cargo test`. The first run executes normally and records its output
|
||||
keyed by a hash of the source tree. Later runs with the same args and an
|
||||
unchanged tree return the cached output. Concurrent callers share a single
|
||||
cargo run via a mkdir lock.
|
||||
|
||||
```bash
|
||||
./scripts/cached-cargo-test.sh --lib
|
||||
./scripts/cached-cargo-test.sh --tests
|
||||
FORCE_CARGO=1 ./scripts/cached-cargo-test.sh --lib # bypass cache
|
||||
```
|
||||
|
||||
Use it for full-suite invocations. Narrow per-test runs (`cargo test
|
||||
some_function`) are fast on their own and just clutter the cache.
|
||||
|
||||
## Capture screenshots
|
||||
|
||||
Regenerates `assets/screenshots/*.png` and `assets/screenshots/demo.gif` for
|
||||
the README and `docs/`. Requires Playwright, ffmpeg, and Python 3 with
|
||||
Pillow on PATH, plus a running `nyx serve` on `$NYX_URL` (default
|
||||
`http://127.0.0.1:9876`). The served scan root must have no prior scans.
|
||||
|
||||
```bash
|
||||
node scripts/capture-screenshots.mjs --stills # only PNGs
|
||||
node scripts/capture-screenshots.mjs --gif # only the GIF
|
||||
node scripts/capture-screenshots.mjs --all # both
|
||||
```
|
||||
|
||||
The script writes a synthetic demo to `$SCAN_ROOT` (default
|
||||
`/tmp/nyx-demo-app`). V1 has four endpoints and produces a 5-hop CMDi
|
||||
taint flow that the GIF drills into. After scan #1 the script overwrites
|
||||
the demo with V2 (just that one flow) and runs scan #2 via the API, so
|
||||
the overview trend chart shows findings going down.
|
||||
|
||||
Stills are captured in two phases:
|
||||
|
||||
- After scan #1 (more findings): `serve-findings-list.png`,
|
||||
`serve-finding-detail.png`.
|
||||
- After scan #2 (trend established): `serve-overview.png`,
|
||||
`serve-triage.png`, `serve-explorer.png`, `serve-scans.png`,
|
||||
`serve-scan-detail.png`, `serve-rules.png`, `serve-config.png`.
|
||||
|
||||
Then `frame-screenshots.py` runs over every captured PNG and wraps it in
|
||||
the brand purple gradient (1800x1113 outer, 1600x992 inner, 12px rounded
|
||||
corners, top-left `#8a5bf5` to bottom-right `#4d1d97`). Finally,
|
||||
`docs/serve-overview.png` is copied to the top-level `overview.png`
|
||||
because that is the path the README references.
|
||||
|
||||
GIF storyboard:
|
||||
|
||||
1. Empty dashboard with the "Run your first scan" prompt.
|
||||
2. Click `Start Scan` in the header bar to open the modal.
|
||||
3. Confirm in the modal and wait for the scan to finish.
|
||||
4. Back to the overview, scroll down through the cards, scroll back.
|
||||
5. Click `Findings` in the sidebar.
|
||||
6. Click the 5-hop taint row.
|
||||
7. On the finding detail, expand Evidence, Analysis Notes, and
|
||||
Confidence Reasoning.
|
||||
8. Open the triage status dropdown and dismiss it.
|
||||
9. Navigate to `/debug/call-graph` for the closing visual.
|
||||
|
||||
To frame an existing PNG without re-capturing:
|
||||
|
||||
```bash
|
||||
python3 scripts/frame-screenshots.py path/to/foo.png [...]
|
||||
```
|
||||
|
||||
Run with no args to re-frame every PNG under `assets/screenshots/`.
|
||||
|
|
|
|||
149
scripts/cached-cargo-test.sh
Executable file
149
scripts/cached-cargo-test.sh
Executable file
|
|
@ -0,0 +1,149 @@
|
|||
#!/bin/bash
|
||||
# Cached cargo test wrapper.
|
||||
#
|
||||
# Returns the cached output of a prior identical `cargo test` run when
|
||||
# the source tree hasn't changed. Concurrent invocations with the same
|
||||
# cache key serialize via a mkdir-based lock — only one cargo run
|
||||
# actually executes; later callers wait, then return the cached result
|
||||
# instantly.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/cached-cargo-test.sh [cargo-test-args...]
|
||||
#
|
||||
# Bypass:
|
||||
# FORCE_CARGO=1 scripts/cached-cargo-test.sh ...
|
||||
#
|
||||
# When to use: full-suite invocations like
|
||||
# scripts/cached-cargo-test.sh --lib
|
||||
# scripts/cached-cargo-test.sh --tests
|
||||
# scripts/cached-cargo-test.sh --test benchmark_test benchmark_evaluation -- --ignored --nocapture
|
||||
#
|
||||
# When NOT to use: narrow per-test runs like
|
||||
# cargo test --test integration_tests rust_web_app
|
||||
# cargo test some_function_name
|
||||
# Those are fast on their own and would just clutter the cache.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
NYX_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
CACHE_DIR="${NYX_CARGO_CACHE_DIR:-/tmp/nyx-cargo-cache}"
|
||||
LOCK_TIMEOUT_SECS=7200 # 2h max wait for a concurrent leader
|
||||
POLL_INTERVAL_SECS=1
|
||||
|
||||
mkdir -p "$CACHE_DIR"
|
||||
cd "$NYX_DIR"
|
||||
|
||||
# ---- portable sha256 ----
|
||||
|
||||
sha256_cmd() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$@"
|
||||
else
|
||||
# macOS ships `shasum`; -a 256 selects sha256 and outputs the same
|
||||
# `<hash> <file>` format as sha256sum.
|
||||
shasum -a 256 "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---- compute cache key ----
|
||||
|
||||
# Hash everything that could affect cargo-test outcomes. Filename of each
|
||||
# input is included in the per-file sha256 line, so renames + additions +
|
||||
# deletions all change the rolled-up hash. The [ -f ] filter drops
|
||||
# deleted-but-still-indexed files so we don't error out on them.
|
||||
compute_source_hash() {
|
||||
{
|
||||
git ls-files src tests benches 2>/dev/null
|
||||
git ls-files --others --exclude-standard src tests benches 2>/dev/null
|
||||
for f in Cargo.toml Cargo.lock build.rs rust-toolchain rust-toolchain.toml; do
|
||||
[ -f "$f" ] && echo "$f"
|
||||
done
|
||||
} | sort -u | while IFS= read -r f; do
|
||||
[ -f "$f" ] && sha256_cmd "$f"
|
||||
done | sha256_cmd | awk '{print $1}'
|
||||
}
|
||||
|
||||
# Hash the args verbatim. -separating with NUL bytes so "--lib" and
|
||||
# "--li b" hash differently.
|
||||
compute_args_hash() {
|
||||
if [ "$#" -eq 0 ]; then
|
||||
printf '' | sha256_cmd | awk '{print $1}'
|
||||
else
|
||||
printf '%s\0' "$@" | sha256_cmd | awk '{print $1}'
|
||||
fi
|
||||
}
|
||||
|
||||
# Hash env vars that can change build/test outcomes.
|
||||
compute_env_hash() {
|
||||
env | grep -E '^(RUST|CARGO|NYX)_' | LC_ALL=C sort \
|
||||
| sha256_cmd | awk '{print $1}'
|
||||
}
|
||||
|
||||
SOURCE_HASH=$(compute_source_hash)
|
||||
ARGS_HASH=$(compute_args_hash "$@")
|
||||
ENV_HASH=$(compute_env_hash)
|
||||
|
||||
KEY="${SOURCE_HASH:0:16}-${ARGS_HASH:0:8}-${ENV_HASH:0:8}"
|
||||
LOG_FILE="$CACHE_DIR/$KEY.log"
|
||||
RC_FILE="$CACHE_DIR/$KEY.rc"
|
||||
LOCK_DIR="$CACHE_DIR/$KEY.lock.d"
|
||||
|
||||
# ---- bypass ----
|
||||
if [ "${FORCE_CARGO:-0}" != "0" ]; then
|
||||
echo "[cached-cargo-test] FORCE_CARGO=1 — bypassing cache" >&2
|
||||
exec cargo test "$@"
|
||||
fi
|
||||
|
||||
# ---- fast path: cache hit, no lock needed ----
|
||||
if [ -f "$LOG_FILE" ] && [ -f "$RC_FILE" ]; then
|
||||
RC=$(cat "$RC_FILE")
|
||||
echo "[cached-cargo-test] cache hit (key $KEY, rc $RC) — source unchanged since prior run" >&2
|
||||
cat "$LOG_FILE"
|
||||
exit "$RC"
|
||||
fi
|
||||
|
||||
# ---- slow path: acquire lock, double-check, run if leader ----
|
||||
|
||||
attempts=0
|
||||
while true; do
|
||||
if mkdir "$LOCK_DIR" 2>/dev/null; then
|
||||
echo "$$" > "$LOCK_DIR/pid"
|
||||
break
|
||||
fi
|
||||
# Stale-lock detection
|
||||
if [ -f "$LOCK_DIR/pid" ]; then
|
||||
OLD_PID=$(cat "$LOCK_DIR/pid" 2>/dev/null || echo "")
|
||||
if [ -n "$OLD_PID" ] && ! kill -0 "$OLD_PID" 2>/dev/null; then
|
||||
echo "[cached-cargo-test] reaping stale lock from dead pid $OLD_PID" >&2
|
||||
rm -rf "$LOCK_DIR" 2>/dev/null
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
if [ "$attempts" -eq 0 ]; then
|
||||
echo "[cached-cargo-test] another invocation is running this same test set; waiting..." >&2
|
||||
fi
|
||||
attempts=$((attempts + 1))
|
||||
if [ "$attempts" -gt "$LOCK_TIMEOUT_SECS" ]; then
|
||||
echo "[cached-cargo-test] gave up waiting for lock after ${LOCK_TIMEOUT_SECS}s" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep "$POLL_INTERVAL_SECS"
|
||||
done
|
||||
|
||||
# Always release the lock on exit, even on failure
|
||||
trap 'rm -rf "$LOCK_DIR" 2>/dev/null' EXIT
|
||||
|
||||
# Double-check: the leader may have populated the cache while we waited.
|
||||
if [ -f "$LOG_FILE" ] && [ -f "$RC_FILE" ]; then
|
||||
RC=$(cat "$RC_FILE")
|
||||
echo "[cached-cargo-test] cache hit after waiting (concurrent leader populated cache, rc $RC)" >&2
|
||||
cat "$LOG_FILE"
|
||||
exit "$RC"
|
||||
fi
|
||||
|
||||
# We're the leader — actually run cargo.
|
||||
echo "[cached-cargo-test] cache miss (key $KEY) — running cargo test $*" >&2
|
||||
cargo test "$@" 2>&1 | tee "$LOG_FILE"
|
||||
RC="${PIPESTATUS[0]}"
|
||||
echo "$RC" > "$RC_FILE"
|
||||
exit "$RC"
|
||||
643
scripts/capture-screenshots.mjs
Normal file
643
scripts/capture-screenshots.mjs
Normal file
|
|
@ -0,0 +1,643 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Capture stills + a demo GIF of the Nyx dashboard for the README/docs.
|
||||
*
|
||||
* The demo source is embedded below (V1_SERVER + V2_SERVER) so the
|
||||
* storyboard is reproducible from this file alone. V1 has 4 endpoints
|
||||
* and yields ~6 findings (one of them a 5-hop CMDi taint flow that
|
||||
* the GIF drills into); V2 keeps only that flow so scan #2 has fewer
|
||||
* findings than scan #1 and the overview trend chart shows a
|
||||
* downward slope.
|
||||
*
|
||||
* Phases:
|
||||
* 1. setup — write V1 to SCAN_ROOT, ensure server reachable
|
||||
* 2. gif (opt) — record the storyboard against a fresh DB; this
|
||||
* also drives scan #1 via the UI
|
||||
* 3. scan #1 — if --gif didn't run, kick off scan #1 via API
|
||||
* 4. stills/p1 — capture pages whose content benefits from many
|
||||
* findings (findings list, finding detail)
|
||||
* 5. patch+scan2 — overwrite SCAN_ROOT with V2 + run scan #2 via API
|
||||
* 6. stills/p2 — capture pages whose content benefits from a
|
||||
* two-scan history (overview trend, scans list,
|
||||
* scan detail) plus the static-ish ones
|
||||
* (triage, explorer, rules, config)
|
||||
* 7. frame — composite the brand purple gradient around every
|
||||
* captured PNG via scripts/frame-screenshots.py
|
||||
*
|
||||
* Prerequisites (script asserts each before starting):
|
||||
* - playwright installed (npx playwright)
|
||||
* - ffmpeg on PATH (palette-based GIF conversion)
|
||||
* - python3 + Pillow on PATH (frame compositing)
|
||||
* - nyx serve running on $NYX_URL (default http://127.0.0.1:9876)
|
||||
* - the served scan root is empty of prior scans (system DB wiped)
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/capture-screenshots.mjs --stills # PNGs only
|
||||
* node scripts/capture-screenshots.mjs --gif # GIF only
|
||||
* node scripts/capture-screenshots.mjs --all # both, in one orchestrated run
|
||||
*
|
||||
* Output (under assets/screenshots/):
|
||||
* demo.gif (~25–30s walkthrough)
|
||||
* overview.png (mirror of docs/serve-overview.png; used by README)
|
||||
* docs/serve-overview.png (overview after scan #2 — trend going down)
|
||||
* docs/serve-findings-list.png (post-scan-#1 list with multiple highs)
|
||||
* docs/serve-finding-detail.png (5-hop taint flow visualizer)
|
||||
* docs/serve-triage.png
|
||||
* docs/serve-explorer.png
|
||||
* docs/serve-scans.png
|
||||
* docs/serve-scan-detail.png
|
||||
* docs/serve-rules.png
|
||||
* docs/serve-config.png
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
rmSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import process from 'node:process';
|
||||
|
||||
const URL_BASE = process.env.NYX_URL || 'http://127.0.0.1:9876';
|
||||
const SCAN_ROOT = process.env.SCAN_ROOT || '/tmp/nyx-demo-app';
|
||||
const OUT_DIR = process.env.OUT_DIR || '/Users/elipeter/nyx/assets/screenshots';
|
||||
const FRAMER = process.env.FRAMER || '/Users/elipeter/nyx/scripts/frame-screenshots.py';
|
||||
const NYX_BIN = process.env.NYX_BIN || '/Users/elipeter/nyx/target/release/nyx';
|
||||
const VIEW = { width: 1440, height: 900 };
|
||||
const COLOR_SCHEME = 'light';
|
||||
|
||||
const args = new Set(process.argv.slice(2));
|
||||
const wantStills = args.has('--stills') || args.has('--all');
|
||||
const wantGif = args.has('--gif') || args.has('--all');
|
||||
const wantCli = args.has('--cli') || args.has('--all');
|
||||
if (!wantStills && !wantGif && !wantCli) {
|
||||
console.error('usage: capture-screenshots.mjs [--stills|--gif|--cli|--all]');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
mkdirSync(join(OUT_DIR, 'docs'), { recursive: true });
|
||||
|
||||
// Demo source ----------------------------------------------------------------
|
||||
|
||||
const V1_SERVER = `import express from 'express';
|
||||
import { exec } from 'child_process';
|
||||
import fs from 'fs';
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// Lookup endpoint. Multi-hop CMDi: req.params.user → trim → flag → cmd → exec.
|
||||
app.get('/lookup/:user', (req, res) => {
|
||||
const raw = req.params.user;
|
||||
const cleaned = raw.trim();
|
||||
const flag = \`--user=\${cleaned}\`;
|
||||
const cmd = \`whois \${flag} --verbose\`;
|
||||
exec(cmd, (err, stdout) => {
|
||||
res.send(stdout);
|
||||
});
|
||||
});
|
||||
|
||||
// SSRF: req.query.url → fetch.
|
||||
app.get('/proxy', async (req, res) => {
|
||||
const target = req.query.url;
|
||||
const response = await fetch(target);
|
||||
const body = await response.text();
|
||||
res.send(body);
|
||||
});
|
||||
|
||||
// Path traversal / unsafe file read.
|
||||
app.get('/file', (req, res) => {
|
||||
const requested = req.query.path;
|
||||
const body = fs.readFileSync(requested, 'utf8');
|
||||
res.send(body);
|
||||
});
|
||||
|
||||
// Login endpoint with weak (Math.random) session id.
|
||||
app.post('/login', (req, res) => {
|
||||
const sid = Math.random().toString(36).slice(2);
|
||||
res.cookie('sid', sid).json({ ok: true });
|
||||
});
|
||||
|
||||
app.listen(3000);
|
||||
`;
|
||||
|
||||
const V2_SERVER = `import express from 'express';
|
||||
import { exec } from 'child_process';
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// Lookup endpoint. Multi-hop CMDi: req.params.user → trim → flag → cmd → exec.
|
||||
app.get('/lookup/:user', (req, res) => {
|
||||
const raw = req.params.user;
|
||||
const cleaned = raw.trim();
|
||||
const flag = \`--user=\${cleaned}\`;
|
||||
const cmd = \`whois \${flag} --verbose\`;
|
||||
exec(cmd, (err, stdout) => {
|
||||
res.send(stdout);
|
||||
});
|
||||
});
|
||||
|
||||
app.listen(3000);
|
||||
`;
|
||||
|
||||
const PACKAGE_JSON = `{ "name": "nyx-demo-app", "version": "1.0.0", "type": "module", "main": "src/server.js" }
|
||||
`;
|
||||
|
||||
const AUTH_JS = `import jwt from 'jsonwebtoken';
|
||||
const SECRET = 'super-secret-key';
|
||||
export function sign(p) { return jwt.sign(p, SECRET); }
|
||||
export function verify(t) { return jwt.verify(t, SECRET); }
|
||||
`;
|
||||
|
||||
function writeDemo(variant) {
|
||||
mkdirSync(join(SCAN_ROOT, 'src'), { recursive: true });
|
||||
writeFileSync(join(SCAN_ROOT, 'package.json'), PACKAGE_JSON);
|
||||
writeFileSync(
|
||||
join(SCAN_ROOT, 'src/server.js'),
|
||||
variant === 'v2' ? V2_SERVER : V1_SERVER,
|
||||
);
|
||||
const authPath = join(SCAN_ROOT, 'src/auth.js');
|
||||
if (variant === 'v1') writeFileSync(authPath, AUTH_JS);
|
||||
if (variant === 'v2' && existsSync(authPath)) unlinkSync(authPath);
|
||||
}
|
||||
|
||||
// Server helpers -------------------------------------------------------------
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function fetchJson(path) {
|
||||
const res = await fetch(URL_BASE + path);
|
||||
if (!res.ok) throw new Error(`${path}: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function csrfToken() {
|
||||
const r = await fetch(URL_BASE + '/api/session');
|
||||
return (await r.json()).csrf_token;
|
||||
}
|
||||
|
||||
async function waitForServer() {
|
||||
for (let i = 0; i < 30; i++) {
|
||||
try { await fetchJson('/api/health'); return; } catch { await sleep(250); }
|
||||
}
|
||||
throw new Error(`nyx serve not reachable at ${URL_BASE}, start it first`);
|
||||
}
|
||||
|
||||
async function startScanViaApi(token) {
|
||||
const res = await fetch(URL_BASE + '/api/scans', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-nyx-csrf': token },
|
||||
body: '{}',
|
||||
});
|
||||
if (!res.ok && res.status !== 409) {
|
||||
throw new Error(`POST /api/scans: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForScanComplete(prevScanId, timeoutMs = 90_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const overview = await fetchJson('/api/overview').catch(() => null);
|
||||
if (
|
||||
overview?.latest_scan_id &&
|
||||
overview.state !== 'empty' &&
|
||||
overview.latest_scan_id !== prevScanId
|
||||
) {
|
||||
await sleep(400);
|
||||
return overview;
|
||||
}
|
||||
await sleep(400);
|
||||
}
|
||||
throw new Error('scan did not complete within deadline');
|
||||
}
|
||||
|
||||
async function currentScanId() {
|
||||
const overview = await fetchJson('/api/overview').catch(() => null);
|
||||
return overview?.latest_scan_id ?? null;
|
||||
}
|
||||
|
||||
// Storyboard helpers ---------------------------------------------------------
|
||||
|
||||
async function findFirstTaintRow(page) {
|
||||
return page.locator('tbody tr').filter({ hasText: 'taint-' }).first();
|
||||
}
|
||||
|
||||
// Stills ---------------------------------------------------------------------
|
||||
|
||||
async function captureStillsAfterScan1(page) {
|
||||
console.error('[stills/p1] findings list');
|
||||
await page.goto(URL_BASE + '/findings');
|
||||
await page.waitForSelector('tbody tr', { timeout: 15_000 });
|
||||
await sleep(1500);
|
||||
await page.screenshot({ path: join(OUT_DIR, 'docs/serve-findings-list.png') });
|
||||
|
||||
console.error('[stills/p1] finding detail (5-hop CMDi)');
|
||||
const row = await findFirstTaintRow(page);
|
||||
await row.click();
|
||||
await page.waitForURL(/\/findings\/\d+/, { timeout: 10_000 });
|
||||
await sleep(2200); // flow visualizer animation
|
||||
await page.screenshot({ path: join(OUT_DIR, 'docs/serve-finding-detail.png') });
|
||||
}
|
||||
|
||||
async function captureStillsAfterScan2(page) {
|
||||
console.error('[stills/p2] overview (with 2-scan trend)');
|
||||
await page.goto(URL_BASE + '/');
|
||||
await page
|
||||
.waitForSelector('.health-score-card, [class*="health"]', { timeout: 10_000 })
|
||||
.catch(() => {});
|
||||
await sleep(1800);
|
||||
await page.screenshot({ path: join(OUT_DIR, 'docs/serve-overview.png') });
|
||||
|
||||
console.error('[stills/p2] triage');
|
||||
await page.goto(URL_BASE + '/triage');
|
||||
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
||||
await sleep(1500);
|
||||
await page.screenshot({ path: join(OUT_DIR, 'docs/serve-triage.png') });
|
||||
|
||||
console.error('[stills/p2] explorer');
|
||||
await page.goto(URL_BASE + '/explorer');
|
||||
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
||||
await page.waitForSelector('.tree-node', { timeout: 10_000 }).catch(() => {});
|
||||
await sleep(1000);
|
||||
// Expand the src/ folder, then click server.js so the screenshot
|
||||
// includes the highlighted source view rather than a "Select a
|
||||
// file" prompt. Best-effort: skip silently if selectors miss.
|
||||
const srcDir = page.locator('.tree-node:has-text("src")').first();
|
||||
if (await srcDir.count()) {
|
||||
await srcDir.click().catch(() => {});
|
||||
await sleep(700);
|
||||
}
|
||||
const serverFile = page.locator('.tree-node:has-text("server.js")').first();
|
||||
if (await serverFile.count()) {
|
||||
await serverFile.click().catch(() => {});
|
||||
await sleep(1500);
|
||||
}
|
||||
await page.screenshot({ path: join(OUT_DIR, 'docs/serve-explorer.png') });
|
||||
|
||||
console.error('[stills/p2] scans list');
|
||||
await page.goto(URL_BASE + '/scans');
|
||||
await page.waitForSelector('tbody tr', { timeout: 10_000 }).catch(() => {});
|
||||
await sleep(1500);
|
||||
await page.screenshot({ path: join(OUT_DIR, 'docs/serve-scans.png') });
|
||||
|
||||
console.error('[stills/p2] scan detail');
|
||||
const firstScan = page.locator('tbody tr').first();
|
||||
if (await firstScan.count()) {
|
||||
await firstScan.click();
|
||||
await page.waitForURL(/\/scans\/\d+/, { timeout: 10_000 }).catch(() => {});
|
||||
await sleep(1800);
|
||||
await page.screenshot({ path: join(OUT_DIR, 'docs/serve-scan-detail.png') });
|
||||
}
|
||||
|
||||
console.error('[stills/p2] rules');
|
||||
await page.goto(URL_BASE + '/rules');
|
||||
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
||||
await sleep(1800);
|
||||
await page.screenshot({ path: join(OUT_DIR, 'docs/serve-rules.png') });
|
||||
|
||||
console.error('[stills/p2] config');
|
||||
await page.goto(URL_BASE + '/config');
|
||||
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
||||
await sleep(1800);
|
||||
await page.screenshot({ path: join(OUT_DIR, 'docs/serve-config.png') });
|
||||
}
|
||||
|
||||
// GIF storyboard -------------------------------------------------------------
|
||||
|
||||
async function captureGifFrames(page) {
|
||||
console.error('[gif] scene 1: empty dashboard');
|
||||
await page.goto(URL_BASE + '/');
|
||||
await page.waitForSelector('text=Run your first scan');
|
||||
await sleep(2200);
|
||||
|
||||
console.error('[gif] scene 2: open Start Scan modal');
|
||||
await page.click('header button:has-text("Start Scan"), .header button:has-text("Start Scan"), button:has-text("Start Scan")');
|
||||
await page.waitForSelector('.scan-modal');
|
||||
await sleep(1200);
|
||||
|
||||
console.error('[gif] scene 3: confirm scan');
|
||||
await page.click('.scan-modal button.btn-primary');
|
||||
await page.waitForURL('**/scans', { timeout: 10_000 }).catch(() => {});
|
||||
await waitForScanComplete(null);
|
||||
|
||||
console.error('[gif] scene 4: back to overview, scroll');
|
||||
await page.goto(URL_BASE + '/');
|
||||
await page
|
||||
.waitForSelector('.health-score-card, [class*="health"]', { timeout: 10_000 })
|
||||
.catch(() => {});
|
||||
await sleep(1800);
|
||||
await page.evaluate(() => window.scrollBy({ top: 360, behavior: 'smooth' }));
|
||||
await sleep(1500);
|
||||
await page.evaluate(() => window.scrollBy({ top: 360, behavior: 'smooth' }));
|
||||
await sleep(1500);
|
||||
await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'smooth' }));
|
||||
await sleep(800);
|
||||
|
||||
console.error('[gif] scene 5: navigate to Findings');
|
||||
await page.click('a.nav-link:has-text("Findings"), .sidebar a:has-text("Findings")');
|
||||
await page.waitForURL('**/findings', { timeout: 10_000 });
|
||||
await page.waitForSelector('tbody tr', { timeout: 10_000 });
|
||||
await sleep(1500);
|
||||
|
||||
console.error('[gif] scene 6: click the 5-hop taint finding');
|
||||
const taintRow = await findFirstTaintRow(page);
|
||||
await taintRow.click();
|
||||
await page.waitForURL(/\/findings\/\d+/, { timeout: 10_000 });
|
||||
await sleep(2500);
|
||||
// Scroll well into the page so the viewer can see the taint flow
|
||||
// animate before the section toggles fire.
|
||||
await page.evaluate(() => window.scrollBy({ top: 480, behavior: 'smooth' }));
|
||||
await sleep(1600);
|
||||
await page.evaluate(() => window.scrollBy({ top: 360, behavior: 'smooth' }));
|
||||
await sleep(1600);
|
||||
|
||||
console.error('[gif] scene 7: open the collapsed sections');
|
||||
for (const title of ['Evidence', 'Analysis Notes', 'Confidence Reasoning']) {
|
||||
const toggle = page.locator(`.section-toggle:has-text("${title}")`).first();
|
||||
if (await toggle.count()) {
|
||||
await toggle.scrollIntoViewIfNeeded();
|
||||
await sleep(500);
|
||||
await toggle.click();
|
||||
await sleep(1100);
|
||||
}
|
||||
}
|
||||
await sleep(800);
|
||||
|
||||
console.error('[gif] scene 8: mark Investigating');
|
||||
await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'smooth' }));
|
||||
await sleep(900);
|
||||
const statusBtn = page.locator('.status-trigger').first();
|
||||
if (await statusBtn.count()) {
|
||||
await statusBtn.click().catch(() => {});
|
||||
await sleep(1100);
|
||||
const investigating = page.locator('text=Investigating').first();
|
||||
if (await investigating.count()) {
|
||||
await investigating.click().catch(() => {});
|
||||
await sleep(1200);
|
||||
}
|
||||
}
|
||||
|
||||
console.error('[gif] scene 9: triage page (closing visual)');
|
||||
await page.goto(URL_BASE + '/triage');
|
||||
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
||||
await sleep(1500);
|
||||
}
|
||||
|
||||
async function convertWebmToGif(webm, gifOut) {
|
||||
const palette = '/tmp/nyx-demo-palette.png';
|
||||
console.error('[gif] generating palette');
|
||||
execFileSync('ffmpeg', [
|
||||
'-y', '-ss', '1.0', '-i', webm,
|
||||
'-vf', 'fps=15,scale=1440:-1:flags=lanczos,palettegen',
|
||||
palette,
|
||||
], { stdio: 'inherit' });
|
||||
console.error('[gif] palette → gif');
|
||||
execFileSync('ffmpeg', [
|
||||
'-y', '-ss', '1.0', '-i', webm, '-i', palette,
|
||||
'-lavfi', 'fps=15,scale=1440:-1:flags=lanczos [x]; [x][1:v] paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle',
|
||||
gifOut,
|
||||
], { stdio: 'inherit' });
|
||||
}
|
||||
|
||||
// CLI capture phase ----------------------------------------------------------
|
||||
//
|
||||
// `render-cli.py` orchestrates: force ANSI via `CLICOLOR_FORCE=1`,
|
||||
// merge consecutive SGR escapes (freeze otherwise honors only the
|
||||
// last one and drops fg/bg/dim), invoke freeze with the brand-
|
||||
// consistent window chrome, then crop or pad to exactly 1600x992 so
|
||||
// the framer never resamples the captured text.
|
||||
|
||||
const CLI_RENDERER = '/Users/elipeter/nyx/scripts/render-cli.py';
|
||||
|
||||
function renderCli(shellCommand, outFile) {
|
||||
execFileSync(
|
||||
'python3',
|
||||
[CLI_RENDERER, outFile, shellCommand],
|
||||
{ stdio: ['ignore', 'inherit', 'inherit'] },
|
||||
);
|
||||
}
|
||||
|
||||
// Stage a temporary HOME with a sample nyx.local so that
|
||||
// `nyx config show` (which now defaults to a diff view) has
|
||||
// something to display. Without this the capture would be a one-
|
||||
// line "No overrides" notice — accurate but not a useful screenshot.
|
||||
//
|
||||
// The `directories` crate on macOS resolves the config path through
|
||||
// `$HOME/Library/Application Support/nyx`, so swapping HOME is
|
||||
// enough to redirect both reads and writes for the wrapped command.
|
||||
const DEMO_CONFIG_HOME = '/tmp/nyx-demo-config-home';
|
||||
const DEMO_NYX_LOCAL = `[scanner]
|
||||
mode = "taint"
|
||||
min_severity = "Medium"
|
||||
|
||||
[output]
|
||||
default_format = "json"
|
||||
max_low = 5
|
||||
|
||||
[analysis.engine]
|
||||
backwards_analysis = true
|
||||
`;
|
||||
|
||||
function stageDemoConfigHome() {
|
||||
const cfgDir = join(DEMO_CONFIG_HOME, 'Library/Application Support/nyx');
|
||||
rmSync(DEMO_CONFIG_HOME, { recursive: true, force: true });
|
||||
mkdirSync(cfgDir, { recursive: true });
|
||||
writeFileSync(join(cfgDir, 'nyx.local'), DEMO_NYX_LOCAL);
|
||||
}
|
||||
|
||||
function captureCli() {
|
||||
// Re-stage v1 so cli-scan output shows the richer set of findings
|
||||
// (the previous --stills phase patched the demo to v2).
|
||||
console.error('[cli/setup] writing v1 demo');
|
||||
writeDemo('v1');
|
||||
|
||||
const out = (name) => join(OUT_DIR, 'docs', name);
|
||||
|
||||
// README and quickstart both link to the same `nyx scan` capture —
|
||||
// emit it once at the top level, no docs/cli-scan-quickstart copy.
|
||||
console.error('[cli] cli-scan');
|
||||
renderCli(`${NYX_BIN} scan ${SCAN_ROOT}`, join(OUT_DIR, 'cli-scan.png'));
|
||||
|
||||
console.error('[cli] cli-failon');
|
||||
// `; true` keeps the pipeline's exit code at 0 even when --fail-on
|
||||
// trips. render-cli.py wraps the whole compound in `{ ...; }
|
||||
// 2>/dev/null` so progress bars are suppressed for both halves.
|
||||
renderCli(
|
||||
`${NYX_BIN} scan ${SCAN_ROOT} --fail-on HIGH; true`,
|
||||
out('cli-failon.png'),
|
||||
);
|
||||
|
||||
console.error('[cli] cli-explain-engine');
|
||||
renderCli(
|
||||
`${NYX_BIN} scan ${SCAN_ROOT} --engine-profile deep --explain-engine`,
|
||||
out('cli-explain-engine.png'),
|
||||
);
|
||||
|
||||
console.error('[cli] cli-idxstatus');
|
||||
renderCli(`${NYX_BIN} index status ${SCAN_ROOT}`, out('cli-idxstatus.png'));
|
||||
|
||||
console.error('[cli] cli-configshow (with staged nyx.local)');
|
||||
stageDemoConfigHome();
|
||||
renderCli(
|
||||
`HOME=${DEMO_CONFIG_HOME} ${NYX_BIN} config show`,
|
||||
out('cli-configshow.png'),
|
||||
);
|
||||
|
||||
// cli-rollup-tail.png is intentionally not regenerated. Its alt text
|
||||
// describes a 57-issue rollup that the synthetic demo cannot produce
|
||||
// without a much larger fixture; the existing image is left alone.
|
||||
}
|
||||
|
||||
// Frame phase ----------------------------------------------------------------
|
||||
|
||||
const STILLS_PNGS = [
|
||||
'docs/serve-overview.png',
|
||||
'docs/serve-findings-list.png',
|
||||
'docs/serve-finding-detail.png',
|
||||
'docs/serve-triage.png',
|
||||
'docs/serve-explorer.png',
|
||||
'docs/serve-scans.png',
|
||||
'docs/serve-scan-detail.png',
|
||||
'docs/serve-rules.png',
|
||||
'docs/serve-config.png',
|
||||
];
|
||||
|
||||
const CLI_PNGS = [
|
||||
'cli-scan.png',
|
||||
'docs/cli-failon.png',
|
||||
'docs/cli-explain-engine.png',
|
||||
'docs/cli-idxstatus.png',
|
||||
'docs/cli-configshow.png',
|
||||
];
|
||||
|
||||
function applyFrames(captured, { natural = false } = {}) {
|
||||
// Frame only paths captured this run. Re-framing a previously-
|
||||
// framed PNG would treat the framed result as the next inner
|
||||
// content and produce a frame inside a frame.
|
||||
const paths = captured.filter((p) => existsSync(p));
|
||||
if (paths.length === 0) return;
|
||||
const label = natural ? 'natural-size' : 'fixed';
|
||||
console.error(`[frame] applying purple gradient frame (${label}) to ${paths.length} files`);
|
||||
const args = natural ? ['--natural', ...paths] : paths;
|
||||
execFileSync('python3', [FRAMER, ...args], { stdio: 'inherit' });
|
||||
// Mirror the framed serve-overview.png to the top-level path the
|
||||
// README links. Only do this when serve-overview was just
|
||||
// captured this run; otherwise the existing top-level overview is
|
||||
// already correct.
|
||||
const ovSrc = join(OUT_DIR, 'docs/serve-overview.png');
|
||||
const ovDst = join(OUT_DIR, 'overview.png');
|
||||
if (paths.includes(ovSrc) && existsSync(ovSrc)) {
|
||||
copyFileSync(ovSrc, ovDst);
|
||||
console.error(`[frame] mirrored serve-overview.png → overview.png`);
|
||||
}
|
||||
}
|
||||
|
||||
// Main -----------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
// Only the serve flows actually need nyx serve; --cli alone runs
|
||||
// without it.
|
||||
if (wantStills || wantGif) await waitForServer();
|
||||
|
||||
console.error('[setup] writing v1 demo to', SCAN_ROOT);
|
||||
writeDemo('v1');
|
||||
|
||||
const needsBrowser = wantStills || wantGif;
|
||||
let browser = null;
|
||||
if (needsBrowser) {
|
||||
const { chromium } = await import('playwright');
|
||||
browser = await chromium.launch({ headless: true });
|
||||
}
|
||||
|
||||
try {
|
||||
if (wantGif) {
|
||||
const videoDir = '/tmp/nyx-demo-video';
|
||||
if (existsSync(videoDir)) rmSync(videoDir, { recursive: true });
|
||||
mkdirSync(videoDir, { recursive: true });
|
||||
|
||||
const ctx = await browser.newContext({
|
||||
viewport: VIEW,
|
||||
colorScheme: COLOR_SCHEME,
|
||||
recordVideo: { dir: videoDir, size: VIEW },
|
||||
});
|
||||
await ctx.addInitScript(() => {
|
||||
try { localStorage.setItem('theme', 'light'); } catch {}
|
||||
});
|
||||
const page = await ctx.newPage();
|
||||
await captureGifFrames(page);
|
||||
await page.close();
|
||||
await ctx.close();
|
||||
|
||||
const fs = await import('node:fs');
|
||||
const files = fs.readdirSync(videoDir).filter((f) => f.endsWith('.webm'));
|
||||
if (files.length === 0) throw new Error('no webm captured');
|
||||
await convertWebmToGif(join(videoDir, files[0]), join(OUT_DIR, 'demo.gif'));
|
||||
} else if (wantStills) {
|
||||
// --stills only: GIF didn't run, so we drive scan #1 ourselves.
|
||||
console.error('[setup] running scan #1 (v1) via API');
|
||||
const token = await csrfToken();
|
||||
const before = await currentScanId();
|
||||
await startScanViaApi(token);
|
||||
await waitForScanComplete(before);
|
||||
}
|
||||
|
||||
if (wantStills) {
|
||||
const ctx = await browser.newContext({ viewport: VIEW, colorScheme: COLOR_SCHEME });
|
||||
await ctx.addInitScript(() => {
|
||||
try { localStorage.setItem('theme', 'light'); } catch {}
|
||||
});
|
||||
const page = await ctx.newPage();
|
||||
|
||||
// Phase 1: capture pages that benefit from many findings.
|
||||
await captureStillsAfterScan1(page);
|
||||
|
||||
// Patch demo to v2 + run scan #2 silently to populate the
|
||||
// trend chart with two data points (second one smaller).
|
||||
console.error('[setup] patching demo to v2 + running scan #2 via API');
|
||||
writeDemo('v2');
|
||||
const token = await csrfToken();
|
||||
const before = await currentScanId();
|
||||
await startScanViaApi(token);
|
||||
await waitForScanComplete(before);
|
||||
|
||||
// Phase 2: capture pages whose value depends on the trend or
|
||||
// are independent of the scan history.
|
||||
await captureStillsAfterScan2(page);
|
||||
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
if (wantCli) {
|
||||
captureCli();
|
||||
}
|
||||
|
||||
if (wantStills || wantCli || wantGif) {
|
||||
// Frame phase — only frame what was captured this run so that
|
||||
// already-framed PNGs from prior runs aren't framed again.
|
||||
// Stills and the GIF use the fixed 1600x992 inner; CLI captures
|
||||
// use --natural so each command keeps its own height.
|
||||
const fixed = [];
|
||||
if (wantStills) fixed.push(...STILLS_PNGS.map((p) => join(OUT_DIR, p)));
|
||||
if (wantGif) fixed.push(join(OUT_DIR, 'demo.gif'));
|
||||
if (fixed.length) applyFrames(fixed, { natural: false });
|
||||
|
||||
if (wantCli) {
|
||||
const cli = CLI_PNGS.map((p) => join(OUT_DIR, p));
|
||||
applyFrames(cli, { natural: true });
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
}
|
||||
|
||||
console.error('done');
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('FAIL:', e);
|
||||
process.exit(1);
|
||||
});
|
||||
222
scripts/frame-screenshots.py
Normal file
222
scripts/frame-screenshots.py
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Frame Nyx screenshots with the brand purple gradient.
|
||||
|
||||
Reads a list of PNG paths from argv (or all PNGs under
|
||||
assets/screenshots/ if no args) and overwrites each with a framed
|
||||
version: inner screenshot with rounded corners, centered on a
|
||||
diagonal purple gradient (top-left #8a5bf5 → bottom-right #4d1d97).
|
||||
|
||||
Two framing modes:
|
||||
- default inner is resampled to 1600x992, outer is 1800x1113.
|
||||
Used for serve-* PNGs whose source is 1440x900.
|
||||
- --natural inner is kept at its native size, outer grows to
|
||||
match (inner + 100/60/100/61 padding). Used for
|
||||
CLI captures whose height varies per command.
|
||||
|
||||
Usage:
|
||||
python3 scripts/frame-screenshots.py path/to/foo.png ...
|
||||
python3 scripts/frame-screenshots.py --natural path/to/cli.png ...
|
||||
python3 scripts/frame-screenshots.py # frames the default set
|
||||
|
||||
Framing is not idempotent — re-framing an already-framed image will
|
||||
re-pad it, so callers are expected to keep raw captures separately or
|
||||
re-capture before re-framing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
# Frame geometry (matches existing docs/serve-*.png files).
|
||||
OUTER_W, OUTER_H = 1800, 1113
|
||||
PAD_L, PAD_T = 100, 60
|
||||
INNER_W, INNER_H = 1600, 992
|
||||
PAD_R = OUTER_W - INNER_W - PAD_L # 100
|
||||
PAD_B = OUTER_H - INNER_H - PAD_T # 61
|
||||
CORNER_RADIUS = 12
|
||||
|
||||
# Four-corner bilinear gradient. Sampled from the existing CLI
|
||||
# screenshots so every framed asset matches: top-left is the lightest
|
||||
# (Tailwind violet-500), the off-diagonal corners are violet-600, and
|
||||
# bottom-right is violet-900.
|
||||
GRAD_TL = (139, 92, 246) # #8b5cf6 violet-500
|
||||
GRAD_TR = (124, 58, 237) # #7c3aed violet-600
|
||||
GRAD_BL = (124, 58, 237) # #7c3aed violet-600
|
||||
GRAD_BR = ( 76, 29, 149) # #4c1d95 violet-900
|
||||
|
||||
|
||||
def make_gradient(w: int, h: int) -> Image.Image:
|
||||
"""Bilinear gradient between the four GRAD_* corners.
|
||||
|
||||
Implemented row-by-row with PIL's linear-interpolation paste so a
|
||||
1800x1113 canvas builds in a few hundred ms (vs ~10s for a pure-
|
||||
Python pixel loop).
|
||||
"""
|
||||
# Top edge: TL → TR
|
||||
top_row = Image.new("RGB", (w, 1))
|
||||
top_pixels = top_row.load()
|
||||
for x in range(w):
|
||||
t = x / (w - 1) if w > 1 else 0.0
|
||||
top_pixels[x, 0] = (
|
||||
int(GRAD_TL[0] + (GRAD_TR[0] - GRAD_TL[0]) * t),
|
||||
int(GRAD_TL[1] + (GRAD_TR[1] - GRAD_TL[1]) * t),
|
||||
int(GRAD_TL[2] + (GRAD_TR[2] - GRAD_TL[2]) * t),
|
||||
)
|
||||
# Bottom edge: BL → BR
|
||||
bot_row = Image.new("RGB", (w, 1))
|
||||
bot_pixels = bot_row.load()
|
||||
for x in range(w):
|
||||
t = x / (w - 1) if w > 1 else 0.0
|
||||
bot_pixels[x, 0] = (
|
||||
int(GRAD_BL[0] + (GRAD_BR[0] - GRAD_BL[0]) * t),
|
||||
int(GRAD_BL[1] + (GRAD_BR[1] - GRAD_BL[1]) * t),
|
||||
int(GRAD_BL[2] + (GRAD_BR[2] - GRAD_BL[2]) * t),
|
||||
)
|
||||
# Vertically blend top row → bottom row across each column.
|
||||
out = Image.new("RGB", (w, h))
|
||||
for y in range(h):
|
||||
t = y / (h - 1) if h > 1 else 0.0
|
||||
# Per-row blend of the two edge images.
|
||||
row = Image.blend(top_row, bot_row, t)
|
||||
out.paste(row, (0, y))
|
||||
return out
|
||||
|
||||
|
||||
def round_corners(img: Image.Image, radius: int) -> Image.Image:
|
||||
"""Apply rounded corners to img by masking alpha."""
|
||||
mask = Image.new("L", img.size, 0)
|
||||
ImageDraw.Draw(mask).rounded_rectangle(
|
||||
(0, 0, img.size[0], img.size[1]), radius=radius, fill=255
|
||||
)
|
||||
out = img.convert("RGBA")
|
||||
out.putalpha(mask)
|
||||
return out
|
||||
|
||||
|
||||
def compose_frame(inner_rgb: Image.Image, gradient_bg: Image.Image) -> Image.Image:
|
||||
"""Resize an RGB frame to the inner target and paste it onto the
|
||||
pre-rendered gradient with rounded corners. Returns an RGB image
|
||||
of OUTER_W x OUTER_H."""
|
||||
inner = inner_rgb
|
||||
if inner.size != (INNER_W, INNER_H):
|
||||
inner = inner.resize((INNER_W, INNER_H), Image.LANCZOS)
|
||||
inner_rounded = round_corners(inner, CORNER_RADIUS)
|
||||
canvas = gradient_bg.copy()
|
||||
canvas.paste(inner_rounded, (PAD_L, PAD_T), inner_rounded)
|
||||
return canvas.convert("RGB")
|
||||
|
||||
|
||||
def compose_frame_natural(inner_rgb: Image.Image) -> Image.Image:
|
||||
"""Frame an inner image at its native size with the same per-edge
|
||||
padding as the fixed-size frame (100/60/100/61). Used for CLI
|
||||
captures whose height varies per command — short ones stay short,
|
||||
long ones stay long, and nothing gets resampled."""
|
||||
inner_w, inner_h = inner_rgb.size
|
||||
outer_w = inner_w + PAD_L + PAD_R
|
||||
outer_h = inner_h + PAD_T + PAD_B
|
||||
bg = make_gradient(outer_w, outer_h).convert("RGBA")
|
||||
inner_rounded = round_corners(inner_rgb, CORNER_RADIUS)
|
||||
bg.paste(inner_rounded, (PAD_L, PAD_T), inner_rounded)
|
||||
return bg.convert("RGB")
|
||||
|
||||
|
||||
def frame_one(src: Path, natural: bool = False) -> None:
|
||||
inner = Image.open(src).convert("RGB")
|
||||
if natural:
|
||||
out = compose_frame_natural(inner)
|
||||
else:
|
||||
bg = make_gradient(OUTER_W, OUTER_H).convert("RGBA")
|
||||
out = compose_frame(inner, bg)
|
||||
out.save(src, "PNG", optimize=True)
|
||||
print(f"framed: {src}", file=sys.stderr)
|
||||
|
||||
|
||||
def frame_gif(src: Path) -> None:
|
||||
"""Frame an animated GIF in place: every frame gets the same
|
||||
purple gradient frame, then the result is re-encoded as a single-
|
||||
palette GIF. Calls ffmpeg for the final encode (Pillow's GIF
|
||||
output is noticeably worse for large animations).
|
||||
"""
|
||||
import subprocess
|
||||
import tempfile
|
||||
from PIL import ImageSequence
|
||||
|
||||
src_img = Image.open(src)
|
||||
bg = make_gradient(OUTER_W, OUTER_H).convert("RGBA")
|
||||
|
||||
durations: list[int] = []
|
||||
with tempfile.TemporaryDirectory(prefix="nyx-gif-frames-") as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
for i, frame in enumerate(ImageSequence.Iterator(src_img)):
|
||||
rgb = frame.convert("RGB")
|
||||
composed = compose_frame(rgb, bg)
|
||||
composed.save(tmp_path / f"{i:05d}.png", "PNG")
|
||||
durations.append(int(frame.info.get("duration", 67)))
|
||||
if not durations:
|
||||
print(f"no frames in {src}", file=sys.stderr)
|
||||
return
|
||||
|
||||
avg_ms = sum(durations) / len(durations)
|
||||
fps = max(1, round(1000.0 / avg_ms))
|
||||
palette = tmp_path / "palette.png"
|
||||
|
||||
# palette pass
|
||||
subprocess.run(
|
||||
[
|
||||
"ffmpeg", "-y",
|
||||
"-framerate", str(fps),
|
||||
"-i", str(tmp_path / "%05d.png"),
|
||||
"-vf", "palettegen=stats_mode=diff",
|
||||
str(palette),
|
||||
],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
# encode
|
||||
subprocess.run(
|
||||
[
|
||||
"ffmpeg", "-y",
|
||||
"-framerate", str(fps),
|
||||
"-i", str(tmp_path / "%05d.png"),
|
||||
"-i", str(palette),
|
||||
"-lavfi", "paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle",
|
||||
"-loop", "0",
|
||||
str(src),
|
||||
],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
print(f"framed gif: {src}", file=sys.stderr)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
natural = False
|
||||
if argv and argv[0] == "--natural":
|
||||
natural = True
|
||||
argv = argv[1:]
|
||||
if not argv:
|
||||
# No args: walk the default location.
|
||||
root = Path(__file__).resolve().parent.parent / "assets" / "screenshots"
|
||||
paths = sorted(p for p in root.rglob("*.png"))
|
||||
else:
|
||||
paths = [Path(p) for p in argv]
|
||||
if not paths:
|
||||
print("no PNGs to frame", file=sys.stderr)
|
||||
return 1
|
||||
for p in paths:
|
||||
if not p.is_file():
|
||||
print(f"skip (not a file): {p}", file=sys.stderr)
|
||||
continue
|
||||
if p.suffix.lower() == ".gif":
|
||||
frame_gif(p)
|
||||
else:
|
||||
frame_one(p, natural=natural)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
70
scripts/render-cli.py
Normal file
70
scripts/render-cli.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Render a shell command's output as a PNG sized to its content.
|
||||
|
||||
Pipeline:
|
||||
1. Run the command, captured through `bash -c` so redirections and
|
||||
env vars work, and force colors via `CLICOLOR_FORCE=1`. The whole
|
||||
compound is wrapped in a brace group so `2>/dev/null` swallows
|
||||
stderr even when the caller chains commands with `;`.
|
||||
2. Pipe through `sgr-merge.py` to consolidate consecutive SGR
|
||||
sequences (freeze otherwise honors only the last one).
|
||||
3. Hand the colored stream to `freeze --execute` at width 1600 with
|
||||
window chrome and let height auto-fit the content.
|
||||
|
||||
Output is freeze's natural-height capture — short commands stay short,
|
||||
long commands stay long. The framer (`frame-screenshots.py --natural`)
|
||||
wraps the result in the brand gradient at the matching outer size, so
|
||||
no resampling or padding ever happens.
|
||||
|
||||
Usage:
|
||||
python3 render-cli.py <out.png> <shell command...>
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
OUT_W = 1600
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
SGR_MERGE = SCRIPT_DIR / "sgr-merge.py"
|
||||
|
||||
|
||||
def run_freeze(shell_cmd: str, out_png: Path) -> None:
|
||||
"""Render the command at width 1600 and font size 22 with window
|
||||
chrome. The brace group around the user command makes
|
||||
`2>/dev/null` apply to the whole compound — without it, a
|
||||
`cmd; true` chain would only redirect `true`'s stderr and leak
|
||||
progress bars from `cmd` into the capture."""
|
||||
inner = (
|
||||
f"{{ CLICOLOR_FORCE=1 {shell_cmd}; }} 2>/dev/null"
|
||||
f" | python3 {shlex.quote(str(SGR_MERGE))}"
|
||||
)
|
||||
subprocess.run(
|
||||
[
|
||||
"freeze",
|
||||
"--execute", f"bash -c {shlex.quote(inner)}",
|
||||
"--output", str(out_png),
|
||||
"--window",
|
||||
"--width", str(OUT_W),
|
||||
"--font.size", "22",
|
||||
],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
if len(argv) < 2:
|
||||
print("usage: render-cli.py <out.png> <shell command...>", file=sys.stderr)
|
||||
return 2
|
||||
out = Path(argv[0])
|
||||
shell_cmd = " ".join(argv[1:])
|
||||
run_freeze(shell_cmd, out)
|
||||
print(f"rendered: {out}", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
36
scripts/sgr-merge.py
Normal file
36
scripts/sgr-merge.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Merge consecutive ANSI SGR escape sequences into a single compound form.
|
||||
|
||||
freeze (charm.sh) renders only the most recently seen SGR escape, so
|
||||
the `console` crate's habit of emitting separate `\x1b[34m\x1b[2m\x1b[4m`
|
||||
sequences erases all but the last attribute. Pipe nyx output through
|
||||
this filter to consolidate runs into `\x1b[34;2;4m` so freeze keeps
|
||||
foreground, dim, and underline.
|
||||
|
||||
Usage: stream stdin -> stdout, e.g. `nyx scan | python3 sgr-merge.py`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
|
||||
SGR_RUN = re.compile(r"(?:\x1b\[(\d+(?:;\d+)*)m){2,}")
|
||||
|
||||
|
||||
def _merge(match: re.Match) -> str:
|
||||
runs = re.findall(r"\x1b\[(\d+(?:;\d+)*)m", match.group(0))
|
||||
return "\x1b[" + ";".join(runs) + "m"
|
||||
|
||||
|
||||
def merge_sgr(s: str) -> str:
|
||||
return SGR_RUN.sub(_merge, s)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
data = sys.stdin.buffer.read().decode("utf-8", errors="replace")
|
||||
sys.stdout.buffer.write(merge_sgr(data).encode("utf-8"))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue