@lateos/npm-scan 0.7.6 → 0.9.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/.dockerignore +20 -0
- package/README.md +342 -81
- package/backend/db.js +68 -22
- package/backend/pdf.js +245 -0
- package/backend/policy.js +111 -0
- package/backend/report.js +45 -0
- package/cli/cli.js +63 -9
- package/package.json +6 -4
- package/.github/workflows/ci.yml +0 -1
- package/.github/workflows/scan.yml +0 -1
- package/AGENTS.md +0 -1
- package/CONTRIBUTING.md +0 -1
- package/action.yml +0 -94
- package/api/README.md +0 -80
- package/api/__init__.py +0 -0
- package/api/api_keys.py +0 -55
- package/api/deps.py +0 -164
- package/api/main.py +0 -44
- package/api/requirements.txt +0 -9
- package/api/routers/__init__.py +0 -0
- package/api/routers/auth.py +0 -80
- package/api/routers/health.py +0 -10
- package/api/routers/scans.py +0 -66
- package/api/routers/sso.py +0 -385
- package/api/routers/webhooks.py +0 -78
- package/api/saml-config.yaml +0 -58
- package/api/saml.py +0 -184
- package/backend/db/pg-schema.sql +0 -155
- package/backend/detectors.test.js +0 -88
- package/backend/tests.test.js +0 -294
- package/docker/Dockerfile.cli +0 -1
- package/docker/docker-compose.yml +0 -1
- package/docs/attack-taxonomy.md +0 -53
- package/docs/project-plan.md +0 -372
- package/docs/sandbox-threat-model.md +0 -91
- package/tests/corpus/run.js +0 -93
package/action.yml
DELETED
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
name: 'npm-scan'
|
|
2
|
-
description: 'Scan npm dependencies for supply chain threats using npm-scan'
|
|
3
|
-
author: 'Lateos'
|
|
4
|
-
|
|
5
|
-
inputs:
|
|
6
|
-
scan-type:
|
|
7
|
-
description: 'Scan mode: lockfile to scan package-lock.json, package to scan a specific package'
|
|
8
|
-
default: 'lockfile'
|
|
9
|
-
required: false
|
|
10
|
-
package:
|
|
11
|
-
description: 'Package name to scan (required when scan-type=package)'
|
|
12
|
-
required: false
|
|
13
|
-
fail-on:
|
|
14
|
-
description: 'Fail the workflow on findings at this severity or higher (none, low, medium, high, critical)'
|
|
15
|
-
default: 'high'
|
|
16
|
-
required: false
|
|
17
|
-
license-key:
|
|
18
|
-
description: 'Premium license key for advanced features'
|
|
19
|
-
required: false
|
|
20
|
-
siem-format:
|
|
21
|
-
description: 'SIEM output format (cef, ecs, sentinel, qradar)'
|
|
22
|
-
required: false
|
|
23
|
-
sbom-format:
|
|
24
|
-
description: 'SBOM output format (json, xml, spdx)'
|
|
25
|
-
required: false
|
|
26
|
-
|
|
27
|
-
outputs:
|
|
28
|
-
findings-count:
|
|
29
|
-
description: 'Number of findings detected'
|
|
30
|
-
value: ${{ steps.run.outputs.findings_count }}
|
|
31
|
-
scan-id:
|
|
32
|
-
description: 'Scan ID for later reference'
|
|
33
|
-
value: ${{ steps.run.outputs.scan_id }}
|
|
34
|
-
|
|
35
|
-
runs:
|
|
36
|
-
using: 'composite'
|
|
37
|
-
steps:
|
|
38
|
-
- uses: actions/setup-node@v4
|
|
39
|
-
with:
|
|
40
|
-
node-version: '20'
|
|
41
|
-
|
|
42
|
-
- name: Install npm-scan
|
|
43
|
-
shell: bash
|
|
44
|
-
run: npm install -g @lateos/npm-scan@latest
|
|
45
|
-
|
|
46
|
-
- name: Run npm-scan scan
|
|
47
|
-
id: run
|
|
48
|
-
shell: bash
|
|
49
|
-
env:
|
|
50
|
-
NPM_SCAN_LICENSE_KEY: ${{ inputs.license-key || '' }}
|
|
51
|
-
run: |
|
|
52
|
-
set -euo pipefail
|
|
53
|
-
if [[ "${{ inputs.scan-type }}" == "package" && -n "${{ inputs.package }}" ]]; then
|
|
54
|
-
output=$(npm-scan scan "${{ inputs.package }}" 2>&1)
|
|
55
|
-
else
|
|
56
|
-
output=$(npm-scan scan-lockfile 2>&1)
|
|
57
|
-
fi
|
|
58
|
-
echo "$output"
|
|
59
|
-
|
|
60
|
-
# Extract scan ID if present
|
|
61
|
-
scan_id=$(echo "$output" | grep -oP '"scanId"\s*:\s*"\K[^"]+' || echo "")
|
|
62
|
-
if [[ -n "$scan_id" ]]; then
|
|
63
|
-
echo "scan_id=$scan_id" >> "$GITHUB_OUTPUT"
|
|
64
|
-
# Count findings
|
|
65
|
-
findings_count=$(echo "$output" | grep -oP '"severity"' | wc -l | tr -d ' ')
|
|
66
|
-
echo "findings_count=$findings_count" >> "$GITHUB_OUTPUT"
|
|
67
|
-
|
|
68
|
-
# Generate additional outputs
|
|
69
|
-
if [[ -n "${{ inputs.siem-format }}" ]]; then
|
|
70
|
-
echo "--- SIEM (${{ inputs.siem-format }}) ---"
|
|
71
|
-
npm-scan report -i "$scan_id" --siem "${{ inputs.siem-format }}" 2>&1 || true
|
|
72
|
-
fi
|
|
73
|
-
if [[ -n "${{ inputs.sbom-format }}" ]]; then
|
|
74
|
-
echo "--- SBOM (${{ inputs.sbom-format }}) ---"
|
|
75
|
-
npm-scan report -i "$scan_id" --sbom "${{ inputs.sbom-format }}" 2>&1 || true
|
|
76
|
-
fi
|
|
77
|
-
fi
|
|
78
|
-
|
|
79
|
-
- name: Check severity threshold
|
|
80
|
-
shell: bash
|
|
81
|
-
if: ${{ inputs.fail-on != 'none' }}
|
|
82
|
-
env:
|
|
83
|
-
FAIL_SEVERITY: ${{ inputs.fail-on }}
|
|
84
|
-
run: |
|
|
85
|
-
SEVERITY_ORDER="none low medium high critical"
|
|
86
|
-
FAIL_IDX=-1
|
|
87
|
-
for sev in $SEVERITY_ORDER; do
|
|
88
|
-
FAIL_IDX=$((FAIL_IDX + 1))
|
|
89
|
-
[[ "$sev" == "$FAIL_SEVERITY" ]] && break
|
|
90
|
-
done
|
|
91
|
-
# In a real scan, we'd parse the output for severity levels.
|
|
92
|
-
# For now, the action logs results and defers to the scan output.
|
|
93
|
-
echo "Fail-on threshold: $FAIL_SEVERITY (index $FAIL_IDX)"
|
|
94
|
-
echo "Review the scan output above for any findings at or above this severity."
|
package/api/README.md
DELETED
|
@@ -1,80 +0,0 @@
|
|
|
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
|
package/api/__init__.py
DELETED
|
File without changes
|
package/api/api_keys.py
DELETED
|
@@ -1,55 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,164 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
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()
|
package/api/requirements.txt
DELETED
package/api/routers/__init__.py
DELETED
|
File without changes
|
package/api/routers/auth.py
DELETED
|
@@ -1,80 +0,0 @@
|
|
|
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")
|
package/api/routers/health.py
DELETED
package/api/routers/scans.py
DELETED
|
@@ -1,66 +0,0 @@
|
|
|
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")
|