1 Commits

Author SHA1 Message Date
MrSphay
5b176851c8 fix: classify acled auth diagnostics
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 5s
Build / test-and-image (pull_request) Successful in 50s
2026-05-17 14:03:18 +02:00
9 changed files with 295 additions and 36 deletions

2
.github/CODEOWNERS vendored
View File

@@ -1 +1 @@
* @MrSphay
* @calesthio

View File

@@ -1,5 +1,5 @@
blank_issues_enabled: true
contact_links:
- name: Security report
url: https://git.wilkensxl.de/MrSphay/intelligence-terminal
url: mailto:celesthioailabs@gmail.com
about: Report security issues privately instead of opening a public issue.

68
.github/workflows/docker-publish.yml vendored Normal file
View File

@@ -0,0 +1,68 @@
name: Build & Publish Docker Image
on:
push:
branches: [master]
tags: ['v*']
pull_request:
branches: [master]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
if: github.event_name != 'pull_request' && vars.DOCKERHUB_ENABLED == 'true'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
${{ vars.DOCKERHUB_ENABLED == 'true' && format('{0}/{1}', secrets.DOCKERHUB_USERNAME, 'crucix') || '' }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -1,6 +1,6 @@
# Contributing to Intelligence Terminal
# Contributing to Crucix
Intelligence Terminal moves quickly, but review bandwidth is limited. The easiest way to get a change merged is to keep it small, well-scoped, and aligned with the project's private home-server deployment direction.
Crucix moves quickly, but review bandwidth is limited. The easiest way to get a change merged is to keep it small, well-scoped, and aligned with the project's direction.
## What Contributions Are Most Helpful

View File

@@ -4,12 +4,22 @@
**Your own intelligence terminal. 27 sources. One command. Zero cloud.**
## [Visit The Live Site: crucix.live](https://www.crucix.live/)
[![Live Website](https://img.shields.io/badge/live-crucix.live-00d4ff?style=for-the-badge)](https://www.crucix.live/)
[![Open Demo](https://img.shields.io/badge/open-live%20dashboard-0b1220?style=for-the-badge&logo=googlechrome&logoColor=white)](https://www.crucix.live/)
[![Node.js 22+](https://img.shields.io/badge/node-22%2B-brightgreen)](#quick-start)
[![License: AGPL v3](https://img.shields.io/badge/license-AGPLv3-blue.svg)](LICENSE)
[![Dependencies](https://img.shields.io/badge/dependencies-1%20(express)-orange)](#architecture)
[![Sources](https://img.shields.io/badge/OSINT%20sources-27-cyan)](#data-sources-27)
[![Docker](https://img.shields.io/badge/docker-ready-blue?logo=docker)](#docker)
**Enter The Signal Network**
[![Signal Wire](https://img.shields.io/badge/Signal%20Wire-%40crucixmonitor-111111?style=for-the-badge&logo=x&logoColor=white)](https://x.com/crucixmonitor)
[![Ops Room](https://img.shields.io/badge/Ops%20Room-Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/ChVy7SF4)
![Crucix Dashboard](docs/dashboard.png)
<details>
@@ -27,13 +37,15 @@
</div>
> **Supported deployment:** private home-server or lab deployment through Docker, Dockge, Pangolin, or local Node.js.
> Runtime data stays in your configured `runs/` volume and API keys are operator-owned.
> **Live website:** [https://www.crucix.live/](https://www.crucix.live/)
> Explore the public demo first, then clone the repo to run Crucix locally.
Crucix pulls satellite fire detection, flight tracking, radiation monitoring, satellite constellation tracking, economic indicators, live market prices, conflict data, sanctions lists, and social sentiment from 27 open-source intelligence feeds — in parallel, every 15 minutes — and renders everything on a single self-contained Jarvis-style dashboard.
Hook it up to an LLM and it becomes a **two-way intelligence assistant** — pushing multi-tier alerts to Telegram and Discord when something meaningful changes, responding to commands like `/brief` and `/sweep` from your phone, and generating actionable trade ideas grounded in real cross-domain data. Your own analyst that watches the world while you sleep.
Try the live demo first at [https://www.crucix.live/](https://www.crucix.live/), then clone the repo when you want the full local stack.
No cloud. No telemetry. No subscriptions. Just `node server.mjs` and you're running.
## Token / Asset Warning
@@ -59,8 +71,8 @@ It was built for anyone who wants to understand what's actually happening in the
```bash
# 1. Clone the repo
git clone ssh://git@git.wilkensxl.de:2222/MrSphay/intelligence-terminal.git
cd intelligence-terminal
git clone https://github.com/calesthio/Crucix.git
cd Crucix
# 2. Install dependencies (just Express)
npm install
@@ -628,13 +640,27 @@ To update them: run the dashboard, wait for a sweep to complete, then use your b
Found a bug? Want to add a 28th source? PRs welcome. Each source is a standalone module in `apis/sources/` — just export a `briefing()` function that returns structured data and add it to the orchestrator in `apis/briefing.mjs`.
If you find this useful, a star helps others find it too.
For contribution guidelines, review expectations, and source-add rules, see `CONTRIBUTING.md`. For security reports, see `SECURITY.md`.
## Contact
For bugs and feature requests, use the Gitea issues in this repository so discussion stays visible and actionable.
For partnerships, integrations, or other non-issue inquiries, you can reach me at `celesthioailabs@gmail.com`.
This fork keeps upstream Crucix attribution through the AGPL-3.0 license and commit history while documenting the supported private Gitea/Docker deployment path here.
For bugs and feature requests, please use GitHub Issues so discussion stays visible and actionable.
---
## Star History
<a href="https://www.star-history.com/?repos=calesthio%2FCrucix&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=calesthio/Crucix&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=calesthio/Crucix&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/image?repos=calesthio/Crucix&type=date&legend=top-left" />
</picture>
</a>
---

View File

@@ -2,13 +2,13 @@
## Reporting a Vulnerability
If you discover a security issue in Intelligence Terminal, please report it privately instead of opening a public issue.
If you discover a security issue in Crucix, please report it privately instead of opening a public GitHub issue.
Use the private security contact configured for this Gitea repository or contact the repository owner directly.
Email: `celesthioailabs@gmail.com`
Use a subject line like:
`[Intelligence Terminal Security] short description`
`[Crucix Security] short description`
Please include:

View File

@@ -15,6 +15,31 @@ const API_BASE = 'https://acleddata.com/api/acled/read';
// Session cache
let sessionCache = { cookies: null, token: null, method: null, expires: 0 };
function acledCredentials() {
return {
email: process.env.ACLED_EMAIL || process.env.ACLED_USER || process.env.ACLED_USERNAME,
password: process.env.ACLED_PASSWORD,
};
}
function sanitizeAuthHeaders(headers) {
const safe = { ...headers };
if (safe.Authorization) safe.Authorization = 'Bearer [redacted]';
if (safe.Cookie) safe.Cookie = '[redacted]';
return safe;
}
function classifyAuthFailure(status, body = '') {
if (status === 401 || status === 403) return 'auth_denied';
if (status >= 500) return 'auth_endpoint_failed';
if (/invalid|denied|unauthorized|forbidden/i.test(body)) return 'auth_denied';
return 'auth_endpoint_failed';
}
export function resetAcledSessionForTests() {
sessionCache = { cookies: null, token: null, method: null, expires: 0 };
}
// Strategy 1: Cookie-based session login (mirrors browser login)
async function loginCookie(email, password) {
const controller = new AbortController();
@@ -43,11 +68,14 @@ async function loginCookie(email, password) {
}
const errText = await res.text().catch(() => '');
return { error: `Cookie login failed (HTTP ${res.status}): ${errText.slice(0, 200)}` };
return {
error: `Cookie login failed (HTTP ${res.status}): ${errText.slice(0, 200)}`,
code: classifyAuthFailure(res.status, errText),
};
} catch (e) {
clearTimeout(timer);
const cause = e.cause ? `${e.cause.message || e.cause.code || e.cause}` : '';
return { error: `Cookie login error: ${e.message}${cause}` };
return { error: `Cookie login error: ${e.message}${cause}`, code: 'auth_endpoint_failed' };
}
}
@@ -73,28 +101,30 @@ async function loginOAuth(email, password) {
if (!res.ok) {
const errText = await res.text().catch(() => '');
return { error: `OAuth failed (HTTP ${res.status}): ${errText.slice(0, 200)}` };
return {
error: `OAuth failed (HTTP ${res.status}): ${errText.slice(0, 200)}`,
code: classifyAuthFailure(res.status, errText),
};
}
const data = await res.json();
if (!data.access_token) {
return { error: `OAuth response missing access_token: ${JSON.stringify(data).slice(0, 200)}` };
return { error: `OAuth response missing access_token: ${JSON.stringify(data).slice(0, 200)}`, code: 'auth_endpoint_failed' };
}
return { token: data.access_token };
} catch (e) {
clearTimeout(timer);
const cause = e.cause ? `${e.cause.message || e.cause.code || e.cause}` : '';
return { error: `OAuth error: ${e.message}${cause}` };
return { error: `OAuth error: ${e.message}${cause}`, code: 'auth_endpoint_failed' };
}
}
// Try both auth strategies
async function authenticate() {
const email = process.env.ACLED_EMAIL;
const password = process.env.ACLED_PASSWORD;
const { email, password } = acledCredentials();
if (!email || !password) {
return { error: 'No ACLED credentials. Set ACLED_EMAIL and ACLED_PASSWORD in .env.' };
return { error: 'No ACLED credentials. Set ACLED_EMAIL or ACLED_USER and ACLED_PASSWORD in .env.', code: 'no_credentials' };
}
// Return cached session if still valid
@@ -108,7 +138,7 @@ async function authenticate() {
// Try OAuth first (official programmatic method per ACLED docs)
const oauthResult = await loginOAuth(email, password);
if (oauthResult.token) {
if (debug) console.error(`[ACLED DEBUG] OAuth OK — token: ${oauthResult.token.slice(0, 20)}...`);
if (debug) console.error('[ACLED DEBUG] OAuth OK');
sessionCache = { cookies: null, token: oauthResult.token, method: 'oauth', expires: Date.now() + 23 * 60 * 60 * 1000 };
return sessionCache;
}
@@ -118,13 +148,14 @@ async function authenticate() {
// Fall back to cookie-based session
const cookieResult = await loginCookie(email, password);
if (cookieResult.cookies) {
if (debug) console.error(`[ACLED DEBUG] Cookie OK — cookies: ${cookieResult.cookies.slice(0, 80)}...`);
if (debug) console.error('[ACLED DEBUG] Cookie OK');
sessionCache = { cookies: cookieResult.cookies, token: null, method: 'cookie', expires: Date.now() + 12 * 60 * 60 * 1000 };
return sessionCache;
}
errors.push(`Cookie: ${cookieResult.error}`);
return { error: `All ACLED auth methods failed.\n${errors.join('\n')}` };
const code = [oauthResult.code, cookieResult.code].includes('auth_denied') ? 'auth_denied' : 'auth_endpoint_failed';
return { error: `All ACLED auth methods failed.\n${errors.join('\n')}`, code };
}
// Build headers based on auth method
@@ -160,7 +191,7 @@ export async function getEvents(opts = {}) {
} = opts;
const session = await authenticate();
if (session.error) return { error: session.error };
if (session.error) return { error: session.error, code: session.code || 'auth_failed' };
const params = new URLSearchParams({ _format: 'json', limit: String(limit) });
if (eventDateStart && eventDateEnd) {
@@ -177,7 +208,7 @@ export async function getEvents(opts = {}) {
const hdrs = authHeaders(session);
if (debug) {
console.error(`[ACLED DEBUG] Data request: GET ${url}`);
console.error(`[ACLED DEBUG] Headers: ${JSON.stringify(hdrs)}`);
console.error(`[ACLED DEBUG] Headers: ${JSON.stringify(sanitizeAuthHeaders(hdrs))}`);
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 25000);
@@ -201,25 +232,28 @@ export async function getEvents(opts = {}) {
+ ' 3. Ensure your account has the "API" access group\n'
+ ' Contact access@acleddata.com if issues persist.'
: '';
return { error: `ACLED data access denied (HTTP ${res.status}, auth method: ${session.method}). Response: ${errText.slice(0, 300)}${hint}` };
return {
error: `ACLED data access denied (HTTP ${res.status}, auth method: ${session.method}). Response: ${errText.slice(0, 300)}${hint}`,
code: 'auth_denied',
};
}
return { error: `HTTP ${res.status}: ${errText.slice(0, 200)}` };
return { error: `HTTP ${res.status}: ${errText.slice(0, 200)}`, code: 'api_failed' };
}
const data = await res.json();
// ACLED may return a 200 with an error status in the body
if (data?.status && data.status !== 200) {
return { error: `ACLED API error: status ${data.status} ${data.message || 'Unknown error'}` };
return { error: `ACLED API error: status ${data.status} - ${data.message || 'Unknown error'}`, code: 'api_failed' };
}
return data;
} catch (e) {
if (e.name === 'AbortError') {
return { error: 'ACLED data request timed out (25s)' };
return { error: 'ACLED data request timed out (25s)', code: 'api_failed' };
}
const rootCause = e.cause ? `${e.cause.message || e.cause.code || e.cause}` : '';
return { error: `ACLED data error: ${e.message}${rootCause ? ' ' + rootCause : ''}` };
return { error: `ACLED data error: ${e.message}${rootCause ? ' - ' + rootCause : ''}`, code: 'api_failed' };
}
}
@@ -237,12 +271,13 @@ function groupBy(events, field) {
// Briefing — last 7 days of global conflict events
export async function briefing() {
if (!process.env.ACLED_EMAIL || !process.env.ACLED_PASSWORD) {
const { email, password } = acledCredentials();
if (!email || !password) {
return {
source: 'ACLED',
timestamp: new Date().toISOString(),
status: 'no_credentials',
message: 'Set ACLED_EMAIL and ACLED_PASSWORD in .env. Register at https://acleddata.com/user/register',
message: 'Set ACLED_EMAIL or ACLED_USER and ACLED_PASSWORD in .env. Register at https://acleddata.com/user/register',
};
}
@@ -256,7 +291,8 @@ export async function briefing() {
});
if (data?.error) {
return { source: 'ACLED', timestamp: new Date().toISOString(), error: data.error };
const status = data.code === 'auth_denied' ? 'auth_failed' : 'api_failed';
return { source: 'ACLED', timestamp: new Date().toISOString(), status, error: data.error };
}
let events = data?.data || [];
@@ -300,6 +336,7 @@ export async function briefing() {
return {
source: 'ACLED',
timestamp: new Date().toISOString(),
status: 'live',
period: { start, end },
totalEvents: events.length,
totalFatalities,
@@ -307,6 +344,7 @@ export async function briefing() {
byType,
topCountries,
deadliestEvents,
message: events.length === 0 ? 'ACLED returned a valid empty event set for the requested period.' : undefined,
};
}

View File

@@ -2,8 +2,9 @@
Provides conflict events, fatalities, event types, and locations.
- Auth: `ACLED_EMAIL` and `ACLED_PASSWORD`.
- Auth: `ACLED_EMAIL` or `ACLED_USER`, plus `ACLED_PASSWORD`.
- Flow: OAuth password grant is tried first, then cookie session fallback.
- Failure modes: missing credentials, terms/profile not completed, expired token, account missing API access.
- Failure modes: missing credentials (`no_credentials`), rejected credentials or access denied (`auth_failed`), token/API endpoint failure (`api_failed`), and valid empty event sets (`totalEvents: 0`).
- Behavior: missing or rejected credentials produce degraded source health with the ACLED error text.
- Debug logs redact bearer tokens and cookies.
- Test: set credentials, run `node apis/sources/acled.mjs`, then check `/api/health`.

View File

@@ -34,3 +34,129 @@ test('safeFetchText returns text and byte count', async () => {
globalThis.fetch = originalFetch;
}
});
function jsonResponse(payload, ok = true, status = 200) {
return {
ok,
status,
headers: { getSetCookie: () => [], get: () => 'application/json' },
text: async () => JSON.stringify(payload),
json: async () => payload,
};
}
function textResponse(text, ok = false, status = 500) {
return {
ok,
status,
headers: { getSetCookie: () => [], get: () => 'text/plain' },
text: async () => text,
json: async () => JSON.parse(text),
};
}
async function withAcledEnv(mockFetch, fn) {
const originalFetch = globalThis.fetch;
const saved = {
ACLED_EMAIL: process.env.ACLED_EMAIL,
ACLED_USER: process.env.ACLED_USER,
ACLED_USERNAME: process.env.ACLED_USERNAME,
ACLED_PASSWORD: process.env.ACLED_PASSWORD,
};
globalThis.fetch = mockFetch;
delete process.env.ACLED_EMAIL;
delete process.env.ACLED_USER;
delete process.env.ACLED_USERNAME;
delete process.env.ACLED_PASSWORD;
const acled = await import('../apis/sources/acled.mjs');
acled.resetAcledSessionForTests();
try {
return await fn(acled);
} finally {
globalThis.fetch = originalFetch;
for (const [key, value] of Object.entries(saved)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
acled.resetAcledSessionForTests();
}
}
test('ACLED credentialed OAuth success returns live events and supports ACLED_USER', async () => {
const responses = [
jsonResponse({ access_token: 'secret-token' }),
jsonResponse({
status: 200,
data: [{
event_date: '2026-05-17',
event_type: 'Protests',
sub_event_type: 'Peaceful protest',
country: 'Example',
region: 'Example Region',
location: 'Example City',
fatalities: '0',
latitude: '1.23',
longitude: '4.56',
}],
}),
];
await withAcledEnv(async () => responses.shift(), async ({ briefing }) => {
process.env.ACLED_USER = 'operator@example.test';
process.env.ACLED_PASSWORD = 'password';
const data = await briefing();
assert.equal(data.status, 'live');
assert.equal(data.totalEvents, 1);
assert.equal(data.topCountries.Example.count, 1);
});
});
test('ACLED rejected credentials return auth_failed diagnostics', async () => {
const responses = [
textResponse('invalid credentials', false, 401),
textResponse('forbidden', false, 403),
];
await withAcledEnv(async () => responses.shift(), async ({ briefing }) => {
process.env.ACLED_EMAIL = 'operator@example.test';
process.env.ACLED_PASSWORD = 'wrong-password';
const data = await briefing();
assert.equal(data.status, 'auth_failed');
assert.match(data.error, /All ACLED auth methods failed/);
});
});
test('ACLED token endpoint failure returns api_failed diagnostics', async () => {
const responses = [
textResponse('temporary outage', false, 503),
textResponse('temporary outage', false, 503),
];
await withAcledEnv(async () => responses.shift(), async ({ briefing }) => {
process.env.ACLED_EMAIL = 'operator@example.test';
process.env.ACLED_PASSWORD = 'password';
const data = await briefing();
assert.equal(data.status, 'api_failed');
assert.match(data.error, /All ACLED auth methods failed/);
});
});
test('ACLED valid empty response is live with zero events', async () => {
const responses = [
jsonResponse({ access_token: 'secret-token' }),
jsonResponse({ status: 200, data: [] }),
];
await withAcledEnv(async () => responses.shift(), async ({ briefing }) => {
process.env.ACLED_EMAIL = 'operator@example.test';
process.env.ACLED_PASSWORD = 'password';
const data = await briefing();
assert.equal(data.status, 'live');
assert.equal(data.totalEvents, 0);
assert.match(data.message, /valid empty/);
});
});