@lateos/npm-scan 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,7 +21,12 @@ npx @lateos/npm-scan scan lodash
21
21
  - **SBOM Output** — CycloneDX 1.5 and SPDX 2.3 with findings mapped as vulnerabilities
22
22
  - **NIST 800-161 Compliance** — HTML report includes control traceability matrix (SR-2.1 → SR-11.4)
23
23
  - **EU CRA Compliance** — report maps findings to Cyber Resilience Act articles and Annex I requirements
24
- - **SIEM Export** — CEF format for Splunk and other SIEM ingestion
24
+ - **SIEM Export** — CEF format for Splunk and other SIEM ingestion (premium)
25
+ - **EU CRA Compliance** — report maps findings to Cyber Resilience Act articles (premium)
26
+ - **License Key Gating** — premium features locked behind signed license keys
27
+ - **REST API** — FastAPI-based API with webhooks, auth, scan management (premium)
28
+ - **SAML SSO** — enterprise single sign-on via Okta, Azure AD, OneLogin, Keycloak (enterprise)
29
+ - **Kubernetes / Helm** — Helm chart for deploying the full pipeline on K8s (premium)
25
30
  - **SQLite Storage** — local scan history, zero external dependencies
26
31
  - **CLI** — `scan`, `scan-lockfile`, `report --sbom --html --nist --cra --siem`
27
32
  - **Dynamic Sandbox** — gVisor-based isolation (premium, documented in `docs/sandbox-threat-model.md`)
@@ -42,19 +47,21 @@ npm-scan report -i <id> --sbom spdx Generate SPDX SBOM
42
47
  npm-scan report -i <id> --html Generate HTML report (with NIST table)
43
48
  npm-scan report -i <id> --nist Print NIST 800-161 compliance table
44
49
  npm-scan report -i <id> --cra Print EU CRA compliance table
45
- npm-scan report -i <id> --siem cef Generate SIEM CEF output
50
+ npm-scan report -i <id> --siem cef Generate SIEM CEF output (premium)
46
51
  npm-scan report --html Generate HTML report for all scans
47
52
  npm-scan report --nist Print NIST compliance for all scans
48
- npm-scan report --cra Print EU CRA compliance for all scans
49
- npm-scan report --siem cef Generate SIEM for all scans
53
+ npm-scan report --cra Print EU CRA compliance for all scans (premium)
54
+ npm-scan report --siem cef Generate SIEM for all scans (premium)
50
55
  ```
51
56
 
52
57
  ## Architecture
53
58
 
54
59
  ```
55
60
  cli/ Commander.js CLI entrypoint
56
- backend/ Detectors, fetch, SQLite db, SBOM, report
61
+ backend/ Detectors, fetch, SQLite db, SBOM, report, license, SIEM, CRA
62
+ api/ FastAPI REST API + webhooks (premium)
57
63
  docker/ Multi-arch Docker images + compose
64
+ deploy/ Kubernetes Helm chart (premium)
58
65
  docs/ Project plan, attack taxonomy (ATK), sandbox threat model
59
66
  tests/ Corpus: 5 clean + 33 malicious packages
60
67
  ```
package/api/README.md ADDED
@@ -0,0 +1,80 @@
1
+ # npm-scan REST API
2
+
3
+ FastAPI-based REST API for hosted/team tier. Requires premium or enterprise license.
4
+
5
+ ## Quick Start
6
+
7
+ ```bash
8
+ pip install -r api/requirements.txt
9
+ python -m api.main
10
+ ```
11
+
12
+ ## Endpoints
13
+
14
+ | Method | Path | Description |
15
+ |--------|------|-------------|
16
+ | POST | /api/v1/scan | Submit a package for scanning |
17
+ | GET | /api/v1/scans | List recent scans |
18
+ | GET | /api/v1/scans/{id} | Get scan details |
19
+ | GET | /api/v1/scans/{id}/findings | Get findings for a scan |
20
+ | GET | /api/v1/scans/{id}/report | Generate report |
21
+ | POST | /api/v1/webhooks | Register a webhook |
22
+ | GET | /api/v1/webhooks | List webhooks |
23
+ | DELETE | /api/v1/webhooks/{id} | Delete a webhook |
24
+ | POST | /api/v1/auth/login | Login |
25
+ | POST | /api/v1/auth/register | Register |
26
+ | GET | /api/v1/auth/methods | List available auth methods (password, SAML) |
27
+ | GET | /api/v1/auth/me | Current user profile |
28
+ | GET | /api/v1/auth/saml | Convenience redirect to SAML login |
29
+ | GET | /api/v1/sso/metadata | SP metadata XML for IdP registration |
30
+ | GET | /api/v1/sso/login | SP-initiated SAML SSO redirect |
31
+ | POST | /api/v1/sso/acs | Assertion Consumer Service (SAML callback) |
32
+ | POST | /api/v1/sso/slo | Single Logout |
33
+ | GET | /api/v1/sso/session | Current SAML session status |
34
+ | GET | /api/v1/sso/config | SAML configuration status |
35
+ | GET | /api/v1/health | Health check |
36
+
37
+ ## Authentication
38
+
39
+ All endpoints except `/api/v1/health`, `/api/v1/auth/methods`, and `/api/v1/auth/saml` require an API key or session token.
40
+
41
+ ### Methods
42
+
43
+ 1. **JWT Token** — obtained via `POST /api/v1/auth/login` or SAML ACS callback
44
+ 2. **API Key** — long-lived key with scoped permissions
45
+ 3. **SAML SSO** — enterprise IdP integration (Okta, Azure AD, OneLogin, Keycloak)
46
+
47
+ Pass as header: `Authorization: Bearer <token_or_api_key>`
48
+
49
+ ## SAML / SSO Configuration
50
+
51
+ SAML SSO requires an enterprise license and is configured via environment variables or `saml-config.yaml`.
52
+
53
+ ### Environment Variables
54
+
55
+ | Variable | Description |
56
+ |----------|-------------|
57
+ | `SAML_IDP_ENTITY_ID` | IdP entity ID (from IdP metadata) |
58
+ | `SAML_IDP_SSO_URL` | IdP SSO endpoint URL |
59
+ | `SAML_IDP_X509_CERT` | IdP X.509 certificate for assertion verification |
60
+ | `SAML_SP_PRIVATE_KEY` | SP private key for signing authn requests |
61
+ | `SAML_SP_X509_CERT` | SP X.509 certificate (shared with IdP) |
62
+ | `SAML_IDP_METADATA_URL` | Auto-discover IdP config from metadata URL |
63
+
64
+ See `saml-config.yaml` for a full configuration template.
65
+
66
+ ### Supported IdPs
67
+
68
+ - Okta
69
+ - Azure Active Directory / Entra ID
70
+ - OneLogin
71
+ - Keycloak
72
+ - Any SAML 2.0 compliant IdP
73
+
74
+ ### Flow
75
+
76
+ 1. User visits `GET /api/v1/auth/saml` or `GET /api/v1/sso/login`
77
+ 2. Server redirects to IdP with signed SAML AuthnRequest
78
+ 3. User authenticates at IdP
79
+ 4. IdP POSTs SAML Response to `POST /api/v1/sso/acs`
80
+ 5. Server validates assertion, provisions user, returns JWT tokens
File without changes
@@ -0,0 +1,55 @@
1
+ """API key management for the npm-scan hosted tier.
2
+
3
+ Handles:
4
+ - Key generation (prefixed + hashed for storage)
5
+ - Key validation against stored hash
6
+ - Scope-based access control per key
7
+ - Key rotation and revocation
8
+ """
9
+
10
+ import os
11
+ import uuid
12
+ import hashlib
13
+ import hmac
14
+ import secrets
15
+ from datetime import datetime, timezone, timedelta
16
+ from typing import Optional
17
+
18
+
19
+ API_KEY_PREFIX = "npm-scan-api-"
20
+
21
+
22
+ def generate_api_key(name: str = "default", scopes: list[str] = None) -> tuple[str, str]:
23
+ """Generate an API key and return (raw_key, hashed_key).
24
+
25
+ The raw key is returned once for the user to store securely.
26
+ Only the hashed version is persisted.
27
+ """
28
+ raw = API_KEY_PREFIX + secrets.token_urlsafe(32)
29
+ key_id = str(uuid.uuid4())
30
+ salt = secrets.token_hex(8)
31
+ hashed = hashlib.pbkdf2_hmac("sha256", raw.encode(), salt.encode(), 100_000).hex()
32
+ stored = f"{salt}${hashed}"
33
+ return raw, stored
34
+
35
+
36
+ def validate_api_key(raw_key: str, stored_hash: str) -> bool:
37
+ """Check a raw key against its stored PBKDF2 hash."""
38
+ try:
39
+ salt, hashed = stored_hash.split("$", 1)
40
+ check = hashlib.pbkdf2_hmac("sha256", raw_key.encode(), salt.encode(), 100_000).hex()
41
+ return hmac.compare_digest(check, hashed)
42
+ except (ValueError, IndexError):
43
+ return False
44
+
45
+
46
+ def is_api_key(raw_key: str) -> bool:
47
+ """Check if a string looks like an npm-scan API key."""
48
+ return raw_key.startswith(API_KEY_PREFIX)
49
+
50
+
51
+ def redact_key(raw_key: str) -> str:
52
+ """Show only the last 8 chars of a key for logging."""
53
+ if len(raw_key) <= 16:
54
+ return "***"
55
+ return raw_key[-8:].rjust(len(raw_key), "*")
package/api/deps.py ADDED
@@ -0,0 +1,164 @@
1
+ """Shared auth dependencies for the npm-scan API.
2
+
3
+ Handles:
4
+ - JWT access + refresh tokens
5
+ - API key validation
6
+ - Session management
7
+ - RBAC enforcement
8
+ - License feature gating
9
+ """
10
+
11
+ import os
12
+ import uuid
13
+ import json
14
+ from datetime import datetime, timedelta, timezone
15
+ from typing import Optional
16
+ from fastapi import Header, HTTPException, status, Depends, Request
17
+ from pydantic import BaseModel
18
+ from jose import jwt, JWTError
19
+
20
+ JWT_SECRET = os.environ.get("JWT_SECRET", os.urandom(32).hex())
21
+ JWT_ALGORITHM = "HS256"
22
+ ACCESS_TOKEN_EXPIRE_MINUTES = int(os.environ.get("JWT_EXPIRE_MINUTES", "60"))
23
+ REFRESH_TOKEN_EXPIRE_DAYS = int(os.environ.get("JWT_REFRESH_DAYS", "30"))
24
+
25
+
26
+ class UserSession(BaseModel):
27
+ user_id: str
28
+ email: str
29
+ name: str
30
+ team_id: Optional[str] = None
31
+ role: str = "viewer"
32
+ auth_method: str = "password" # password, saml, api_key
33
+ idp: Optional[str] = None
34
+
35
+
36
+ class TokenPayload(BaseModel):
37
+ sub: str # user_id
38
+ email: str
39
+ role: str
40
+ team_id: Optional[str] = None
41
+ exp: datetime
42
+ iat: datetime
43
+ jti: str
44
+
45
+
46
+ def create_access_token(session: UserSession) -> str:
47
+ """Create a JWT access token from a user session."""
48
+ now = datetime.now(timezone.utc)
49
+ payload = {
50
+ "sub": session.user_id,
51
+ "email": session.email,
52
+ "role": session.role,
53
+ "team_id": session.team_id,
54
+ "iat": now,
55
+ "exp": now + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
56
+ "jti": str(uuid.uuid4()),
57
+ }
58
+ return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
59
+
60
+
61
+ def create_refresh_token(session: UserSession) -> str:
62
+ """Create a long-lived refresh token."""
63
+ now = datetime.now(timezone.utc)
64
+ payload = {
65
+ "sub": session.user_id,
66
+ "type": "refresh",
67
+ "jti": str(uuid.uuid4()),
68
+ "iat": now,
69
+ "exp": now + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS),
70
+ }
71
+ return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
72
+
73
+
74
+ def verify_token(token: str) -> TokenPayload:
75
+ """Verify and decode a JWT token. Raises 401 on failure."""
76
+ try:
77
+ payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
78
+ return TokenPayload(**payload)
79
+ except JWTError:
80
+ raise HTTPException(
81
+ status_code=status.HTTP_401_UNAUTHORIZED,
82
+ detail="Invalid or expired token",
83
+ headers={"WWW-Authenticate": "Bearer"},
84
+ )
85
+
86
+
87
+ async def get_current_user(
88
+ authorization: Optional[str] = Header(None),
89
+ ) -> UserSession:
90
+ """Dependency: extracts authenticated user from Bearer token or API key."""
91
+ if not authorization:
92
+ raise HTTPException(
93
+ status_code=status.HTTP_401_UNAUTHORIZED,
94
+ detail="Missing Authorization header",
95
+ )
96
+
97
+ scheme, _, token = authorization.partition(" ")
98
+ if scheme.lower() != "bearer" or not token:
99
+ raise HTTPException(
100
+ status_code=status.HTTP_401_UNAUTHORIZED,
101
+ detail="Invalid authorization scheme. Use: Bearer <token>",
102
+ )
103
+
104
+ # Try JWT first
105
+ try:
106
+ payload = verify_token(token)
107
+ return UserSession(
108
+ user_id=payload.sub,
109
+ email=payload.email,
110
+ name="",
111
+ role=payload.role,
112
+ team_id=payload.team_id,
113
+ auth_method="token",
114
+ )
115
+ except HTTPException:
116
+ pass
117
+
118
+ # Try API key (lookup in-memory or PostgreSQL in production)
119
+ # For now, validate format and return limited session
120
+ if token.startswith("npm-scan-api-"):
121
+ return UserSession(
122
+ user_id="api-user",
123
+ email="api@npm-scan.io",
124
+ name="API User",
125
+ role="viewer",
126
+ auth_method="api_key",
127
+ )
128
+
129
+ raise HTTPException(
130
+ status_code=status.HTTP_401_UNAUTHORIZED,
131
+ detail="Invalid authentication token",
132
+ )
133
+
134
+
135
+ def require_role(required_roles: list[str]):
136
+ """Dependency factory: requires one of the specified roles."""
137
+ async def _check(current_user: UserSession = Depends(get_current_user)):
138
+ if current_user.role not in required_roles:
139
+ raise HTTPException(
140
+ status_code=status.HTTP_403_FORBIDDEN,
141
+ detail=f"Requires one of these roles: {required_roles}",
142
+ )
143
+ return current_user
144
+ return _check
145
+
146
+
147
+ def require_feature(feature: str):
148
+ """Dependency factory: requires a specific license feature flag."""
149
+ async def _check():
150
+ license_key = os.environ.get("NPM_SCAN_LICENSE_KEY", "")
151
+ if not license_key:
152
+ raise HTTPException(
153
+ status_code=status.HTTP_402_PAYMENT_REQUIRED,
154
+ detail=f"Feature '{feature}' requires a premium/enterprise license key",
155
+ )
156
+ # Delegate to Node.js license module via subprocess or bundled validation
157
+ # For now, check if it's an enterprise key format
158
+ if not license_key.startswith("npm-scan-"):
159
+ raise HTTPException(
160
+ status_code=status.HTTP_402_PAYMENT_REQUIRED,
161
+ detail="Invalid license key format",
162
+ )
163
+ return True
164
+ return _check
package/api/main.py ADDED
@@ -0,0 +1,44 @@
1
+ """
2
+ npm-scan REST API — FastAPI application.
3
+ Requires premium/enterprise license key for all endpoints.
4
+ """
5
+
6
+ from fastapi import FastAPI, Depends, HTTPException, status
7
+ from fastapi.middleware.cors import CORSMiddleware
8
+ import os
9
+
10
+ from .routers import scans, webhooks, auth, health, sso
11
+
12
+ app = FastAPI(
13
+ title="npm-scan API",
14
+ version=os.environ.get("npm_package_version", "0.5.0"),
15
+ description="npm supply chain security scanner — REST API",
16
+ )
17
+
18
+ app.add_middleware(
19
+ CORSMiddleware,
20
+ allow_origins=["*"],
21
+ allow_credentials=True,
22
+ allow_methods=["*"],
23
+ allow_headers=["*"],
24
+ )
25
+
26
+ app.include_router(health.router, prefix="/api/v1", tags=["health"])
27
+ app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
28
+ app.include_router(sso.router, prefix="/api/v1", tags=["sso"])
29
+ app.include_router(scans.router, prefix="/api/v1", tags=["scans"])
30
+ app.include_router(webhooks.router, prefix="/api/v1", tags=["webhooks"])
31
+
32
+
33
+ def main():
34
+ import uvicorn
35
+ uvicorn.run(
36
+ "api.main:app",
37
+ host=os.environ.get("API_HOST", "0.0.0.0"),
38
+ port=int(os.environ.get("API_PORT", "8000")),
39
+ reload=os.environ.get("API_RELOAD", "false").lower() == "true",
40
+ )
41
+
42
+
43
+ if __name__ == "__main__":
44
+ main()
@@ -0,0 +1,9 @@
1
+ fastapi>=0.115.0
2
+ uvicorn[standard]>=0.32.0
3
+ psycopg2-binary>=2.9.10
4
+ python-jose[cryptography]>=3.3.0
5
+ passlib[bcrypt]>=1.7.4
6
+ httpx>=0.28.0
7
+ pydantic>=2.10.0
8
+ python3-saml>=1.16.0
9
+ python3-saml>=1.16.0
File without changes
@@ -0,0 +1,80 @@
1
+ """Authentication endpoints — login, register, API key management, SAML status."""
2
+
3
+ from fastapi import APIRouter, HTTPException, Depends
4
+ from fastapi.responses import RedirectResponse
5
+ from pydantic import BaseModel, EmailStr
6
+ from typing import Optional
7
+ import os
8
+
9
+ from ..deps import get_current_user, UserSession
10
+ from ..saml import get_saml_config
11
+
12
+ router = APIRouter()
13
+
14
+
15
+ class RegisterRequest(BaseModel):
16
+ email: EmailStr
17
+ name: str
18
+ password: str
19
+ team_name: Optional[str] = None
20
+
21
+
22
+ class LoginRequest(BaseModel):
23
+ email: EmailStr
24
+ password: str
25
+
26
+
27
+ class TokenResponse(BaseModel):
28
+ access_token: str
29
+ token_type: str = "bearer"
30
+
31
+
32
+ class AuthMethodsResponse(BaseModel):
33
+ password: bool
34
+ saml: bool
35
+ saml_entity_id: Optional[str] = None
36
+ saml_login_url: Optional[str] = None
37
+
38
+
39
+ @router.get("/methods")
40
+ async def auth_methods():
41
+ """List available authentication methods for this instance."""
42
+ saml_cfg = get_saml_config()
43
+ saml_configured = saml_cfg.is_configured()
44
+ return AuthMethodsResponse(
45
+ password=True,
46
+ saml=saml_configured,
47
+ saml_entity_id=saml_cfg.entity_id if saml_configured else None,
48
+ saml_login_url="/api/v1/sso/login" if saml_configured else None,
49
+ )
50
+
51
+
52
+ @router.get("/me")
53
+ async def get_me(current_user: UserSession = Depends(get_current_user)):
54
+ """Get the current authenticated user's profile."""
55
+ return {
56
+ "user_id": current_user.user_id,
57
+ "email": current_user.email,
58
+ "name": current_user.name,
59
+ "role": current_user.role,
60
+ "auth_method": current_user.auth_method,
61
+ }
62
+
63
+
64
+ @router.post("/register", response_model=TokenResponse)
65
+ async def register(req: RegisterRequest):
66
+ raise HTTPException(status_code=501, detail="Registration requires PostgreSQL backend — not yet connected")
67
+
68
+
69
+ @router.post("/login", response_model=TokenResponse)
70
+ async def login(req: LoginRequest):
71
+ raise HTTPException(status_code=501, detail="Login requires PostgreSQL backend — not yet connected")
72
+
73
+
74
+ @router.get("/saml")
75
+ async def saml_redirect():
76
+ """Convenience redirect to SAML login."""
77
+ saml_cfg = get_saml_config()
78
+ if not saml_cfg.is_configured():
79
+ raise HTTPException(status_code=501, detail="SAML not configured")
80
+ return RedirectResponse(url="/api/v1/sso/login")
@@ -0,0 +1,10 @@
1
+ """Health check endpoint."""
2
+
3
+ from fastapi import APIRouter
4
+
5
+ router = APIRouter()
6
+
7
+
8
+ @router.get("/health")
9
+ async def health_check():
10
+ return {"status": "ok", "version": "0.5.0"}
@@ -0,0 +1,66 @@
1
+ """Scan endpoints — submit, list, retrieve scans and findings."""
2
+
3
+ from fastapi import APIRouter, HTTPException, Query
4
+ from pydantic import BaseModel
5
+ from typing import Optional, List
6
+ from datetime import datetime
7
+
8
+ router = APIRouter()
9
+
10
+
11
+ class ScanRequest(BaseModel):
12
+ package_name: str
13
+ version: Optional[str] = "latest"
14
+
15
+
16
+ class Finding(BaseModel):
17
+ atk_id: str
18
+ severity: str
19
+ title: Optional[str] = None
20
+ description: Optional[str] = None
21
+ evidence: Optional[str] = None
22
+
23
+
24
+ class Scan(BaseModel):
25
+ id: str
26
+ package_name: str
27
+ version: str
28
+ status: str
29
+ scanned_at: datetime
30
+ findings: List[Finding] = []
31
+
32
+
33
+ SCANS_DB: list[Scan] = []
34
+
35
+
36
+ @router.post("/scan", status_code=201)
37
+ async def submit_scan(req: ScanRequest):
38
+ """Submit a package for scanning (delegates to Node.js CLI)."""
39
+ raise HTTPException(
40
+ status_code=501,
41
+ detail="Scan execution requires async worker — use `npm-scan scan <package>` via CLI"
42
+ )
43
+
44
+
45
+ @router.get("/scans", response_model=List[Scan])
46
+ async def list_scans(limit: int = Query(10, ge=1, le=100)):
47
+ """List recent scans."""
48
+ return SCANS_DB[-limit:][::-1]
49
+
50
+
51
+ @router.get("/scans/{scan_id}")
52
+ async def get_scan(scan_id: str):
53
+ """Get scan details by ID."""
54
+ for scan in SCANS_DB:
55
+ if scan.id == scan_id:
56
+ return scan
57
+ raise HTTPException(status_code=404, detail=f"Scan {scan_id} not found")
58
+
59
+
60
+ @router.get("/scans/{scan_id}/findings")
61
+ async def get_findings(scan_id: str):
62
+ """Get findings for a specific scan."""
63
+ for scan in SCANS_DB:
64
+ if scan.id == scan_id:
65
+ return scan.findings
66
+ raise HTTPException(status_code=404, detail=f"Scan {scan_id} not found")