Overview — what it does
Security Diagnostics runs a rootkit-trace scan plus a system-hardening scan across your registered servers, scores each result as a 0–100 score with an A–F grade, and shows your whole fleet's security posture on one screen. Every check carries a what/why/how-to-fix explanation, and any finding that is a failure whose severity is critical (fail + crit) is auto-promoted to a Problem Resolution case (deduplicated, and auto-resolved once the check later passes).
Core pieces:
- Probe
infr_seccheck_probe.cjs— a Node script that runs locally and read-only on the target host (no credentials). It performs 36 checks and emits JSON. - Canonical catalogue
App\Support\SecurityCheckbook— owns the 9 categories, check keys, weights, and severities (locale-independent, cache-safe). Explanation text lives inresources/lang/{ko,en}/seccheck.php. - Engine
App\Services\Security\DiagnosticsService— ingest → score → case promotion/resolution → dashboard aggregation. - Storage
infr_sec_scan(migration000766) — one row = one host scan;findingsstored inline as JSON.
Running the probe as root enables privileged checks such as
/etc/shadow,sudoers, andsshd -T. When not root, privileged checks are downgraded toskipand excluded from the score denominator (no penalty).
When to use
- Baseline hardening validation before admitting a new server into production.
- Rapid triage of rootkit / backdoor traces on a suspected host (hidden processes,
ld.so.preloadinjection, promiscuous NIC, etc.). - A recurring compliance rhythm — a daily cron probe that pushes scores via the API reporter to accumulate a trend automatically.
- Audit readiness — see at a glance which hosts run an EOL OS, have unapplied security updates, or permit root SSH login.
Layout
| Area | Contents |
|---|---|
| KPI strip | Fleet average score · scanned host count · open critical cases · total failing checks · last-scan time |
| Grade distribution | Histogram of A/B/C/D/F host counts |
| Category scores | Rollup of the 9 categories (fleet average, catalogue order, tone color) |
| Score trend | Last 60 scans in chronological order |
| Hosts by grade | Host card grid — sorted lowest (riskiest) score first. Each card shows grade, pass/warn/fail/crit counts, intake source, scan time. Click → report modal |
| Report modal | Overall score/grade + summary (OS/kernel) + per-check detail (what/why/fix/evidence) + promoted-case link |
Top toolbar: [Run scan] (multi-server remote run) · [Paste result] · Refresh · Auto-refresh toggle · Fullscreen.
How to use
Four intake tracks
infr_sec_scan.source classifies how a scan was received:
| source | Entry point | Method |
|---|---|---|
emulate |
[Run scan] modal | Select instances → deploy & run the probe remotely. A paste / remote-emulate apply that carries an instance_id also records as emulate |
paste |
[Paste result] modal | Paste probe JSON produced directly on the target, with no instance binding |
api |
POST /api/v1/security/scan |
Each host's cron runs the probe → pushes via an API key (the reporter track) |
manual |
Fallback | Default when the source value is not valid |
1) Multi-server remote run — async polling
A synchronous full run would exceed PHP's execution time across many hosts, so the flow is register → fire-and-forget worker → poll.
- [Run scan] → tick the target instances (each shows reachable / unreachable).
POST /security/diagnostics/run→ creates a progress record and returnsrun_idimmediately (Cache, TTL 30 min).- The front end calls
POST /security/diagnostics/run-worker/{runId}without awaiting → the worker runs all hosts sequentially underset_time_limit(0)andignore_user_abort(one host failing does not stop the rest). - The front end polls
GET /security/diagnostics/run-status/{runId}to update progress (done/total/ok).
An instance marked unreachable does not match a
run_reportcollector node, so it cannot be run remotely. Run the probe directly on the host and submit it via [Paste result]. See Remote Emulate for the remote-run wiring.
2) Paste / JSON ingest
Run the probe on the target host and paste its JSON.
# On the target host (recommended: root)
node infr_seccheck_probe.cjs --min
--min emits single-line JSON. Output shape:
{
"host": "web-01",
"generated_at": "2026-08-13T03:30:00Z",
"os": "Rocky Linux 9.3",
"kernel": "5.14.0-362.el9",
"root": true,
"checks": [
{ "key": "ssh_root_login", "category": "ssh", "status": "fail", "severity": "crit", "detail": "PermitRootLogin yes", "evidence": "/etc/ssh/sshd_config:32" },
{ "key": "svc_firewall", "category": "services", "status": "pass", "severity": "crit", "detail": "firewalld active" }
],
"summary": {}
}
It is received by POST /security/diagnostics/ingest. Binding an instance records source=emulate; leaving it unbound records source=paste.
3) API reporter (automation)
Each host's cron runs the probe and pushes the result into InfraGuard. Issue the API key in Administration ▸ API Keys; the key must carry the scan.write scope.
30 3 * * * node /opt/infr/infr_seccheck_probe.cjs --min | curl -sS -X POST https://infraguard.example.com/api/v1/security/scan -H "Authorization: Bearer YOUR_API_KEY" -H "Content-Type: application/json" --data @-
Response (201):
{ "ok": true, "scan_id": 812, "host": "web-01", "score": 74, "grade": "C" }
Scoring — SecurityCheckbook
The score is a weighted ratio of each check's weight and status.
Status factor (STATUS_FACTOR) — only these count; skip/info are excluded from the denominator:
| Status | Factor |
|---|---|
pass |
1.0 |
warn |
0.6 |
fail |
0.0 |
skip · info |
excluded |
Severity multiplier (SEV_MUL) — the heavier the implication of a failure, the more it moves the score:
| fail_severity | Multiplier |
|---|---|
crit |
1.6 |
warn |
1.0 |
info |
0.5 |
Per-check weight w = check_weight(1–3) × severity_multiplier; overall score = round(100 × Σ(w × status_factor) / Σw). Category scores use the same formula per category.
Grade & tone:
| Score | Grade | Tone |
|---|---|---|
| ≥ 90 | A | good |
| ≥ 80 | B | good |
| ≥ 70 | C | warn |
| ≥ 60 | D | warn |
| < 60 | F | crit |
(Grades are A/B/C/D/F; tone is good ≥ 80 · warn ≥ 60 · crit < 60.)
9 categories · 36 checks
Each category has a display weight and icon; actual scoring uses the per-check weight × severity multiplier above.
| Category | Label | Checks | Notable checks (implication on failure) |
|---|---|---|---|
rootkit |
Rootkit / malware | 6 | rk_known_files·rk_ld_preload·rk_hidden_proc (crit) |
patch |
Patch / updates | 3 | pt_security_updates·pt_eol_os (crit) |
ssh |
SSH configuration | 5 | ssh_root_login·ssh_empty_pass (crit) |
accounts |
Accounts / privileges | 5 | acc_uid0·acc_empty_pass (crit) |
network |
Network exposure | 3 | net_legacy_services·net_db_exposed (crit) |
filesystem |
Filesystem permissions | 5 | fs_shadow_perm (crit)·fs_world_writable·fs_suid (warn) |
hardening |
System hardening | 5 | hd_selinux·hd_aslr·hd_syncookies (warn) |
services |
Services / firewall | 2 | svc_firewall (crit)·svc_unnecessary (info) |
logging |
Audit / logging | 2 | log_auditd·log_syslog (warn) |
Unknown keys are absorbed into the other category. Each check's "what / why / fix" text renders in the report modal from seccheck.check.*.
Critical findings → Problem Resolution promotion
On every ingest, reconcileCases() reconciles the host's cases.
- Only findings with
status = failANDseverity = critpromote. Dedup key: an internalfingerprint. - If an open case exists it bumps
occurrence_count++andlast_occurred_at; otherwise it creates a new case:infr_incident(area=security,source=detection,entity_type=seccheck,entity_key=host,severity=crit,status=open) plus aninfr_incident_event(kind=created). - Auto-resolve: a previously open case that is no longer fail+crit in this scan (fixed or no longer detected) is set to
status=resolvedwithresolved_atand an auto-resolution note.
Promoted checks appear in the report modal with a "promoted to resolution" badge and a [View case] link. Cases are worked on the Security ▸ Problem Resolution board.
Deleting a scan (
POST /security/diagnostics/delete) does not delete its promoted cases — they are independent.
Data sources
| Source | Role |
|---|---|
infr_sec_scan (000766) |
Scan results (one per host). score·grade·pass/warn/fail/crit/total_count·category_scores·summary·findings JSON. Indexes (host,scanned_at)·(instance_id,scanned_at)·grade·score |
infr_instance |
Target instances (resolves host → instance_id by alias/hostname) |
infr_incident / infr_incident_event |
Promoted critical cases and their timeline |
infr_api_key |
API reporter auth (scope scan.write) |
| Cache | Dashboard aggregate cached 20 s (infr:sec:diag:build) · async run progress record (TTL 1800 s) |
infr_seccheck_probe.cjs |
The source probe (local, read-only) |
- Aggregation discipline: within the most recent 500 scans, keep only the latest one per host (dedupe) → fleet list, average, category rollup, trend, open-critical count.
- The probe's category/severity are hints only; the server re-normalizes them as canonical via
SecurityCheckbook.
Access
- Read: requires access to the Security role area (in the current tier, staff = 5 / admin = 10; demo = 1 does not see the Security area). Views the dashboard and reports.
- Write (run / ingest / delete): requires session
infr_user_level >= 2(canWrite()).run·ingest·deletereturn 403 (seccheck.toast.forbidden) below that. - API reporter: the
infr.apikeymiddleware plus a key with thescan.writescope; otherwise 403forbidden_scope. - Audit: every write is recorded in
infr_audit_log—sec.diag_run(target count),sec.diag_ingest(scan_id·score·source),sec.diag_delete(scan id). Review in the Audit log.
For the level model in general (infr.level:N middleware), see RBAC levels.
Troubleshooting & FAQ
Q. An instance shows "unreachable" in [Run scan].
It doesn't match a run_report collector node, so there is no remote-run wiring. Run the probe on the host directly and submit via [Paste result].
Q. "No checks found" error.
The pasted JSON has no checks (or items) array, or entries lack a key. Paste the output of infr_seccheck_probe.cjs --min verbatim.
Q. The score is low even though the fail count is small.
That's the severity multiplier. One crit failure weighs 1.6× a warn. Use the category scores and report modal to see which finding is pulling the score down.
Q. All privileged checks are skip.
The probe ran as a non-root account. Checks like /etc/shadow, sudoers, sshd -T need root. skip is excluded from the denominator (no penalty), but run as root (or via the gateway) for a full diagnosis.
Q. A run takes a while — can I close the browser?
Yes. The worker runs fire-and-forget and continues under ignore_user_abort even if the request aborts. Reopen and poll run-status, or just read the grades once they appear on the dashboard. The progress record is kept for 30 minutes.
Q. A case disappeared on its own. If a later scan no longer reports that finding as fail+crit (fixed or not detected), the engine auto-resolves the case. That is expected.
Related
- Security ▸ Problem Resolution — workflow for promoted critical cases
- Remote Emulate — multi-server probe deployment & run wiring
- API Keys — reporter-track keys (scope
scan.write) - Assets ▸ Instances — the target OS instance registry
- Security ▸ Overview — the full defense-funnel narrative
- Audit log — run / ingest / delete history
