feat: Phase 1 scaffold — frontend canvas + backend skeleton

Frontend:
- Vite + React 18 + TypeScript + Tailwind v4 + Shadcn/ui
- React Flow v12 canvas with all 11 node types and 5 edge types
- Dark theme with project design system (cyan, green, orange, purple accents)
- Collapsible sidebar, toolbar, detail panel
- Zustand store for canvas state
- Demo data with 10 nodes and 10 edges

Backend:
- FastAPI + SQLAlchemy async + SQLite (Python 3.13)
- DB models: Node, Edge, CanvasState, PendingDevice, ScanRun
- REST API routes: auth, nodes, edges, canvas, scan, status (WebSocket)
- JWT auth + bcrypt via config.yml
- Pydantic v2 schemas

Infra:
- GitHub Actions quality + security workflows
- .gitignore, .env.example, verify-tooling.sh, security-check.sh
This commit is contained in:
Pouzor
2026-03-06 23:14:34 +01:00
commit 4310a5cc2d
32 changed files with 870 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
# Backend - server-side only (NEVER commit .env)
SECRET_KEY=change_me_in_production
SQLITE_PATH=./data/homelab.db
CONFIG_PATH=./config.yml
CORS_ORIGINS=http://localhost:5173
+40
View File
@@ -0,0 +1,40 @@
name: Quality
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
frontend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm test -- --run
backend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- run: pip install -r requirements.txt
- run: ruff check .
- run: mypy app/
- run: pytest
+36
View File
@@ -0,0 +1,36 @@
name: Security
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: '0 9 * * 1' # Weekly on Monday
jobs:
secrets-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Detect secrets
uses: trufflesecurity/trufflehog@main
with:
path: ./
dependency-audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: NPM Audit
run: cd frontend && npm audit --audit-level=high
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Safety check
run: pip install safety && safety check -r backend/requirements.txt
+50
View File
@@ -0,0 +1,50 @@
# Claude / project meta — never commit
CLAUDE.md
FEATURES.md
.claude/
_project_specs/
# Environment files - NEVER commit
.env
.env.*
!.env.example
# Secrets
*.pem
*.key
*.p12
credentials.json
secrets.json
service-account*.json
# Dependencies
node_modules/
__pycache__/
*.pyc
.venv/
venv/
# Build outputs
dist/
build/
# IDE
.idea/
.vscode/settings.json
.DS_Store
# Python
*.egg-info/
.pytest_cache/
.mypy_cache/
.ruff_cache/
htmlcov/
.coverage
# SQLite
*.db
*.db-shm
*.db-wal
# Docker
.docker/
View File
View File
+13
View File
@@ -0,0 +1,13 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.core.security import decode_token
bearer = HTTPBearer()
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(bearer)) -> str:
username = decode_token(credentials.credentials)
if not username:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
return username
View File
+34
View File
@@ -0,0 +1,34 @@
import yaml
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel
from app.core.config import settings
from app.core.security import create_access_token, verify_password
router = APIRouter()
class LoginRequest(BaseModel):
username: str
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
def _load_credentials() -> tuple[str, str]:
with open(settings.config_path) as f:
cfg = yaml.safe_load(f)
auth = cfg.get("auth", {})
return auth.get("username", "admin"), auth.get("password_hash", "")
@router.post("/login", response_model=TokenResponse)
async def login(body: LoginRequest):
username, password_hash = _load_credentials()
if body.username != username or not verify_password(body.password, password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
token = create_access_token(body.username)
return TokenResponse(access_token=token)
+42
View File
@@ -0,0 +1,42 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.db.database import get_db
from app.db.models import CanvasState, Edge, Node
from app.schemas.canvas import CanvasSaveRequest, CanvasStateResponse
router = APIRouter()
@router.get("/", response_model=CanvasStateResponse)
async def load_canvas(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)):
nodes = (await db.execute(select(Node))).scalars().all()
edges = (await db.execute(select(Edge))).scalars().all()
state = await db.get(CanvasState, 1)
viewport = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
return CanvasStateResponse(nodes=list(nodes), edges=list(edges), viewport=viewport)
@router.post("/save")
async def save_canvas(body: CanvasSaveRequest, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)):
# Update node positions from canvas
for node_pos in body.node_positions:
node = await db.get(Node, node_pos.id)
if node:
node.pos_x = node_pos.x
node.pos_y = node_pos.y
# Upsert viewport
state = await db.get(CanvasState, 1)
if state:
state.viewport = body.viewport
state.saved_at = datetime.now(timezone.utc)
else:
db.add(CanvasState(id=1, viewport=body.viewport))
await db.commit()
return {"saved": True}
+46
View File
@@ -0,0 +1,46 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.db.database import get_db
from app.db.models import Edge
from app.schemas.edges import EdgeCreate, EdgeResponse, EdgeUpdate
router = APIRouter()
@router.get("/", response_model=list[EdgeResponse])
async def list_edges(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)):
result = await db.execute(select(Edge))
return result.scalars().all()
@router.post("/", response_model=EdgeResponse, status_code=status.HTTP_201_CREATED)
async def create_edge(body: EdgeCreate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)):
edge = Edge(**body.model_dump())
db.add(edge)
await db.commit()
await db.refresh(edge)
return edge
@router.delete("/{edge_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_edge(edge_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)):
edge = await db.get(Edge, edge_id)
if not edge:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Edge not found")
await db.delete(edge)
await db.commit()
@router.patch("/{edge_id}", response_model=EdgeResponse)
async def update_edge(edge_id: str, body: EdgeUpdate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)):
edge = await db.get(Edge, edge_id)
if not edge:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Edge not found")
for field, value in body.model_dump(exclude_unset=True).items():
setattr(edge, field, value)
await db.commit()
await db.refresh(edge)
return edge
+54
View File
@@ -0,0 +1,54 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.db.database import get_db
from app.db.models import Node
from app.schemas.nodes import NodeCreate, NodeResponse, NodeUpdate
router = APIRouter()
@router.get("/", response_model=list[NodeResponse])
async def list_nodes(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)):
result = await db.execute(select(Node))
return result.scalars().all()
@router.post("/", response_model=NodeResponse, status_code=status.HTTP_201_CREATED)
async def create_node(body: NodeCreate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)):
node = Node(**body.model_dump())
db.add(node)
await db.commit()
await db.refresh(node)
return node
@router.get("/{node_id}", response_model=NodeResponse)
async def get_node(node_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)):
node = await db.get(Node, node_id)
if not node:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Node not found")
return node
@router.patch("/{node_id}", response_model=NodeResponse)
async def update_node(node_id: str, body: NodeUpdate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)):
node = await db.get(Node, node_id)
if not node:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Node not found")
for field, value in body.model_dump(exclude_unset=True).items():
setattr(node, field, value)
await db.commit()
await db.refresh(node)
return node
@router.delete("/{node_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_node(node_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)):
node = await db.get(Node, node_id)
if not node:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Node not found")
await db.delete(node)
await db.commit()
+44
View File
@@ -0,0 +1,44 @@
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.db.database import get_db
from app.db.models import PendingDevice, ScanRun
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
router = APIRouter()
@router.post("/trigger", response_model=ScanRunResponse)
async def trigger_scan(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)):
run = ScanRun(status="running", ranges=[])
db.add(run)
await db.commit()
await db.refresh(run)
# TODO: launch scanner in background thread
return run
@router.get("/pending", response_model=list[PendingDeviceResponse])
async def list_pending(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)):
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "pending"))
return result.scalars().all()
@router.post("/pending/{device_id}/approve")
async def approve_device(device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)):
device = await db.get(PendingDevice, device_id)
if device:
device.status = "approved"
await db.commit()
return {"approved": True}
@router.post("/pending/{device_id}/hide")
async def hide_device(device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)):
device = await db.get(PendingDevice, device_id)
if device:
device.status = "hidden"
await db.commit()
return {"hidden": True}
+33
View File
@@ -0,0 +1,33 @@
import json
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
router = APIRouter()
# Active WebSocket connections
_connections: list[WebSocket] = []
@router.websocket("/ws/status")
async def ws_status(websocket: WebSocket):
await websocket.accept()
_connections.append(websocket)
try:
while True:
await websocket.receive_text()
except WebSocketDisconnect:
_connections.remove(websocket)
async def broadcast_status(node_id: str, status: str, checked_at: str, response_time_ms: int | None = None):
payload = json.dumps({
"node_id": node_id,
"status": status,
"checked_at": checked_at,
"response_time_ms": response_time_ms,
})
for conn in list(_connections):
try:
await conn.send_text(payload)
except Exception:
_connections.remove(conn)
View File
+17
View File
@@ -0,0 +1,17 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
secret_key: str = "change_me_in_production"
sqlite_path: str = "./data/homelab.db"
config_path: str = "./config.yml"
cors_origins: list[str] = ["http://localhost:5173", "http://localhost:3000"]
# JWT
algorithm: str = "HS256"
access_token_expire_minutes: int = 1440 # 24h
settings = Settings()
+30
View File
@@ -0,0 +1,30 @@
from datetime import datetime, timedelta, timezone
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def create_access_token(subject: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
payload = {"sub": subject, "exp": expire}
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
def decode_token(token: str) -> str | None:
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
return payload.get("sub")
except JWTError:
return None
View File
+25
View File
@@ -0,0 +1,25 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings
engine = create_async_engine(
f"sqlite+aiosqlite:///{settings.sqlite_path}",
echo=False,
)
AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def init_db() -> None:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def get_db():
async with AsyncSessionLocal() as session:
yield session
+89
View File
@@ -0,0 +1,89 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.database import Base
def _now() -> datetime:
return datetime.now(timezone.utc)
def _uuid() -> str:
return str(uuid.uuid4())
class Node(Base):
__tablename__ = "nodes"
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
type: Mapped[str] = mapped_column(String, nullable=False)
label: Mapped[str] = mapped_column(String, nullable=False)
hostname: Mapped[str | None] = mapped_column(String)
ip: Mapped[str | None] = mapped_column(String)
mac: Mapped[str | None] = mapped_column(String)
os: Mapped[str | None] = mapped_column(String)
status: Mapped[str] = mapped_column(String, default="unknown")
check_method: Mapped[str | None] = mapped_column(String)
check_target: Mapped[str | None] = mapped_column(String)
services: Mapped[list] = mapped_column(JSON, default=list)
notes: Mapped[str | None] = mapped_column(Text)
pos_x: Mapped[float] = mapped_column(Float, default=0)
pos_y: Mapped[float] = mapped_column(Float, default=0)
parent_id: Mapped[str | None] = mapped_column(String, ForeignKey("nodes.id"))
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
response_time_ms: Mapped[int | None] = mapped_column(Integer)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
children: Mapped[list["Node"]] = relationship("Node", back_populates="parent")
parent: Mapped["Node | None"] = relationship("Node", back_populates="children", remote_side=[id])
class Edge(Base):
__tablename__ = "edges"
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
source: Mapped[str] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE"))
target: Mapped[str] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE"))
type: Mapped[str] = mapped_column(String, default="ethernet")
label: Mapped[str | None] = mapped_column(String)
vlan_id: Mapped[int | None] = mapped_column(Integer)
speed: Mapped[str | None] = mapped_column(String)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class CanvasState(Base):
__tablename__ = "canvas_state"
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
viewport: Mapped[dict] = mapped_column(JSON, default=dict)
saved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class PendingDevice(Base):
__tablename__ = "pending_devices"
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
ip: Mapped[str] = mapped_column(String, nullable=False)
mac: Mapped[str | None] = mapped_column(String)
hostname: Mapped[str | None] = mapped_column(String)
os: Mapped[str | None] = mapped_column(String)
services: Mapped[list] = mapped_column(JSON, default=list)
suggested_type: Mapped[str | None] = mapped_column(String)
status: Mapped[str] = mapped_column(String, default="pending")
discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class ScanRun(Base):
__tablename__ = "scan_runs"
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
status: Mapped[str] = mapped_column(String, default="running")
ranges: Mapped[list] = mapped_column(JSON, default=list)
devices_found: Mapped[int] = mapped_column(Integer, default=0)
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
error: Mapped[str | None] = mapped_column(Text)
+41
View File
@@ -0,0 +1,41 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings
from app.db.database import init_db
from app.api.routes import auth, nodes, edges, canvas, scan, status
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_db()
yield
app = FastAPI(
title="Homelable API",
version="0.1.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
app.include_router(nodes.router, prefix="/api/v1/nodes", tags=["nodes"])
app.include_router(edges.router, prefix="/api/v1/edges", tags=["edges"])
app.include_router(canvas.router, prefix="/api/v1/canvas", tags=["canvas"])
app.include_router(scan.router, prefix="/api/v1/scan", tags=["scan"])
app.include_router(status.router, prefix="/api/v1/status", tags=["status"])
@app.get("/api/v1/health")
async def health():
return {"status": "ok"}
View File
+21
View File
@@ -0,0 +1,21 @@
from pydantic import BaseModel
from app.schemas.edges import EdgeResponse
from app.schemas.nodes import NodeResponse
class NodePosition(BaseModel):
id: str
x: float
y: float
class CanvasSaveRequest(BaseModel):
node_positions: list[NodePosition] = []
viewport: dict = {}
class CanvasStateResponse(BaseModel):
nodes: list[NodeResponse]
edges: list[EdgeResponse]
viewport: dict
+30
View File
@@ -0,0 +1,30 @@
from datetime import datetime
from pydantic import BaseModel
class EdgeBase(BaseModel):
source: str
target: str
type: str = "ethernet"
label: str | None = None
vlan_id: int | None = None
speed: str | None = None
class EdgeCreate(EdgeBase):
pass
class EdgeUpdate(BaseModel):
type: str | None = None
label: str | None = None
vlan_id: int | None = None
speed: str | None = None
class EdgeResponse(EdgeBase):
id: str
created_at: datetime
model_config = {"from_attributes": True}
+51
View File
@@ -0,0 +1,51 @@
from datetime import datetime
from typing import Any
from pydantic import BaseModel
class NodeBase(BaseModel):
type: str
label: str
hostname: str | None = None
ip: str | None = None
mac: str | None = None
os: str | None = None
status: str = "unknown"
check_method: str | None = None
check_target: str | None = None
services: list[Any] = []
notes: str | None = None
pos_x: float = 0
pos_y: float = 0
parent_id: str | None = None
class NodeCreate(NodeBase):
pass
class NodeUpdate(BaseModel):
type: str | None = None
label: str | None = None
hostname: str | None = None
ip: str | None = None
mac: str | None = None
os: str | None = None
status: str | None = None
check_method: str | None = None
check_target: str | None = None
services: list[Any] | None = None
notes: str | None = None
pos_x: float | None = None
pos_y: float | None = None
class NodeResponse(NodeBase):
id: str
last_seen: datetime | None = None
response_time_ms: int | None = None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
+30
View File
@@ -0,0 +1,30 @@
from datetime import datetime
from typing import Any
from pydantic import BaseModel
class PendingDeviceResponse(BaseModel):
id: str
ip: str
mac: str | None
hostname: str | None
os: str | None
services: list[Any]
suggested_type: str | None
status: str
discovered_at: datetime
model_config = {"from_attributes": True}
class ScanRunResponse(BaseModel):
id: str
status: str
ranges: list[str]
devices_found: int
started_at: datetime
finished_at: datetime | None
error: str | None
model_config = {"from_attributes": True}
+13
View File
@@ -0,0 +1,13 @@
auth:
username: admin
# Default password: "admin" — change this before deploying!
# Generate a new hash: python scripts/hash_password.py yourpassword
password_hash: "$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW"
scanner:
ranges:
- "192.168.1.0/24"
interval: null # null = manual only, "hourly" or "daily" for scheduled
status_checker:
interval_seconds: 60
+23
View File
@@ -0,0 +1,23 @@
# Requires Python 3.13+ (3.14 not yet supported by pydantic-core)
fastapi==0.115.0
uvicorn[standard]==0.30.6
sqlalchemy[asyncio]==2.0.35
aiosqlite==0.20.0
alembic==1.13.3
pydantic==2.9.2
pydantic-settings==2.5.2
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.12
apscheduler==3.10.4
python-nmap==0.7.1
pyyaml==6.0.2
websockets==13.1
httpx==0.27.2
# Dev
ruff==0.6.9
mypy==1.11.2
pytest==8.3.3
pytest-asyncio==0.24.0
pytest-cov==5.0.0
+12
View File
@@ -0,0 +1,12 @@
"""Generate a bcrypt password hash for config.yml."""
import sys
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
if len(sys.argv) < 2:
print("Usage: python scripts/hash_password.py <password>")
sys.exit(1)
password = sys.argv[1]
print(pwd_context.hash(password))
Submodule
+1
Submodule frontend added at a19a9632e2
+41
View File
@@ -0,0 +1,41 @@
#!/bin/bash
set -e
echo "Running security checks..."
# Check .env is not staged
if git diff --cached --name-only | grep -E '^\.env$|^\.env\.' | grep -v '\.example$'; then
echo "ERROR: .env file is staged for commit!"
exit 1
fi
# Check for common secret patterns in staged files
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)
if [ -n "$STAGED_FILES" ]; then
if echo "$STAGED_FILES" | xargs grep -l -E '(password|secret|api_key|apikey|token)\s*[:=]\s*["\047][^"\047]{8,}["\047]' 2>/dev/null; then
echo "WARNING: Possible secrets found in staged files - please verify"
fi
fi
# Check for SQLite DB being committed
if git diff --cached --name-only | grep -E '\.db$'; then
echo "ERROR: SQLite database file is staged for commit!"
exit 1
fi
# Dependency audit (frontend)
if [ -f "frontend/package.json" ]; then
echo "Checking npm dependencies..."
cd frontend && npm audit --audit-level=high 2>/dev/null || echo "Warning: npm audit found issues"
cd ..
fi
# Python dependency check
if [ -f "backend/requirements.txt" ]; then
if command -v safety &> /dev/null; then
echo "Checking Python dependencies..."
safety check -r backend/requirements.txt 2>/dev/null || echo "Warning: safety found issues"
fi
fi
echo "Security checks complete!"
+49
View File
@@ -0,0 +1,49 @@
#!/bin/bash
set -e
echo "Verifying project tooling..."
# GitHub CLI
if command -v gh &> /dev/null; then
if gh auth status &> /dev/null; then
echo "✓ GitHub CLI authenticated"
else
echo "✗ GitHub CLI not authenticated. Run: gh auth login"
exit 1
fi
else
echo "⚠ GitHub CLI not installed. Run: brew install gh"
fi
# Python
if command -v python3 &> /dev/null; then
echo "✓ Python $(python3 --version)"
else
echo "✗ Python not installed"
exit 1
fi
# Node
if command -v node &> /dev/null; then
echo "✓ Node $(node --version)"
else
echo "✗ Node not installed"
exit 1
fi
# nmap (required for scanner service)
if command -v nmap &> /dev/null; then
echo "✓ nmap $(nmap --version | head -1)"
else
echo "⚠ nmap not installed. Run: brew install nmap (required for scanner)"
fi
# Docker
if command -v docker &> /dev/null; then
echo "✓ Docker $(docker --version)"
else
echo "⚠ Docker not installed. Required for production deployment."
fi
echo ""
echo "Tooling verification complete!"