@clear-capabilities/agentic-security-scanner 0.134.0 → 0.136.2
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/CHANGELOG.md +196 -0
- package/bin/agentic-security-audit.js +2 -1
- package/bin/agentic-security-consistency.js +2 -1
- package/bin/agentic-security.js +269 -44
- package/dist/113.index.js +6 -6
- package/dist/117.index.js +3 -1
- package/dist/178.index.js +1 -1
- package/dist/220.index.js +5 -3
- package/dist/238.index.js +4 -4
- package/dist/317.index.js +270 -0
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +13 -7
- package/dist/513.index.js +7 -3
- package/dist/526.index.js +6 -6
- package/dist/637.index.js +1 -1
- package/dist/675.index.js +7 -5
- package/dist/839.index.js +4 -3
- package/dist/905.index.js +1173 -0
- package/dist/agentic-security.mjs +14 -14
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/ccpa.json +32 -0
- package/dist/compliance-frameworks/eu-ai-act.json +51 -0
- package/dist/compliance-frameworks/gdpr.json +45 -0
- package/dist/compliance-frameworks/hipaa-security-rule.json +56 -0
- package/dist/compliance-frameworks/nist-ai-600-1.json +51 -0
- package/dist/compliance-frameworks/nist-csf-2.json +73 -0
- package/dist/compliance-frameworks/nist-privacy-1-1.json +846 -0
- package/dist/compliance-frameworks/owasp-asvs-5.json +79 -0
- package/dist/compliance-frameworks/owasp-llm-top-10.json +69 -0
- package/package.json +9 -5
- package/src/badge.js +2 -1
- package/src/dataflow/cross-service-taint.js +2 -1
- package/src/dataflow/ifds-precise.js +6 -4
- package/src/dataflow/incremental.js +7 -5
- package/src/discovery/CLAUDE.md +10 -0
- package/src/discovery/index.js +175 -3
- package/src/discovery/llm-invoke.js +90 -1
- package/src/discovery/memory.js +163 -0
- package/src/engine.js +45 -8
- package/src/integrations/tickets.js +7 -6
- package/src/ir/ir-stats.js +1 -1
- package/src/ir/parser-cpp.js +1 -1
- package/src/leaderboard.js +3 -2
- package/src/llm-validator/consistency.js +6 -2
- package/src/llm-validator/index.js +1 -2
- package/src/mcp/audit.js +2 -1
- package/src/mcp/tools.js +4 -3
- package/src/posture/CLAUDE.md +26 -1
- package/src/posture/accuracy-scorecard.js +59 -1
- package/src/posture/agents-memory.js +5 -3
- package/src/posture/auditor-walkthrough.js +9 -7
- package/src/posture/auth-posture-import.js +5 -4
- package/src/posture/compliance-frameworks/nist-privacy-1-1.json +846 -0
- package/src/posture/compliance-policy.js +7 -9
- package/src/posture/custom-rules.js +7 -5
- package/src/posture/cve-alert-daemon.js +6 -5
- package/src/posture/dep-add-guard.js +2 -1
- package/src/posture/deterministic.js +3 -2
- package/src/posture/evidence-bundle.js +246 -0
- package/src/posture/exploitability-probability.js +2 -1
- package/src/posture/feature-flags.js +3 -2
- package/src/posture/findings-memory.js +3 -3
- package/src/posture/fix-history.js +5 -2
- package/src/posture/fix-metrics.js +5 -5
- package/src/posture/fix-plan.js +2 -1
- package/src/posture/grader-calibration.js +3 -4
- package/src/posture/intent-context.js +2 -1
- package/src/posture/learning.js +4 -3
- package/src/posture/license-attributions.js +5 -7
- package/src/posture/license-graph.js +2 -1
- package/src/posture/license-policy.js +2 -1
- package/src/posture/model-rescan.js +4 -3
- package/src/posture/network-policy-import.js +3 -2
- package/src/posture/pqc-migration-plan.js +7 -5
- package/src/posture/pr-augment.js +8 -5
- package/src/posture/privacy-framework.js +262 -0
- package/src/posture/risk-dollars.js +2 -2
- package/src/posture/router.js +5 -4
- package/src/posture/ruleset-version.js +2 -2
- package/src/posture/runtime-correlation.js +2 -1
- package/src/posture/sbom-diff.js +12 -3
- package/src/posture/sca-policy.js +7 -4
- package/src/posture/scan-checkpoint.js +15 -0
- package/src/posture/state-dir.js +34 -0
- package/src/posture/telemetry-ingest.js +4 -3
- package/src/posture/threat-model-auto.js +4 -1
- package/src/posture/threat-model-grounding.js +11 -1
- package/src/posture/time-to-fix.js +3 -2
- package/src/posture/triage-memory.js +3 -2
- package/src/posture/waf-ingest.js +6 -5
- package/src/posture/watch-mode.js +4 -3
- package/src/sast/code-injection-multilang.js +29 -0
- package/src/sca/dep-confusion.js +2 -1
|
@@ -16,6 +16,7 @@ import * as fs from 'node:fs';
|
|
|
16
16
|
import * as path from 'node:path';
|
|
17
17
|
import * as yaml from '../util/yaml.js';
|
|
18
18
|
|
|
19
|
+
import { statePath } from './state-dir.js';
|
|
19
20
|
const DEFAULT_POLICY = {
|
|
20
21
|
allow: [],
|
|
21
22
|
deny: [],
|
|
@@ -26,7 +27,7 @@ const DEFAULT_POLICY = {
|
|
|
26
27
|
export function loadLicensePolicy(scanRoot) {
|
|
27
28
|
if (!scanRoot) return null;
|
|
28
29
|
for (const name of ['license-policy.yml', 'license-policy.yaml', 'license-policy.json']) {
|
|
29
|
-
const p =
|
|
30
|
+
const p = statePath(scanRoot, name);
|
|
30
31
|
if (!fs.existsSync(p)) continue;
|
|
31
32
|
try {
|
|
32
33
|
const raw = fs.readFileSync(p, 'utf8');
|
|
@@ -15,10 +15,10 @@
|
|
|
15
15
|
import * as fs from 'node:fs';
|
|
16
16
|
import * as path from 'node:path';
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
import { statePath, stateWritesEnabled } from './state-dir.js';
|
|
19
19
|
|
|
20
20
|
function _readJson(scanRoot, name) {
|
|
21
|
-
try { return JSON.parse(fs.readFileSync(
|
|
21
|
+
try { return JSON.parse(fs.readFileSync(statePath(scanRoot, name), 'utf8')); } catch { return null; }
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
/**
|
|
@@ -50,7 +50,8 @@ export function diffValidatorRuns(runA, runB) {
|
|
|
50
50
|
* Persist a model-rescan report. Returns the file path.
|
|
51
51
|
*/
|
|
52
52
|
export function persistRescanReport(scanRoot, from, to, changed) {
|
|
53
|
-
const dir =
|
|
53
|
+
const dir = statePath(scanRoot, 'model-rescan');
|
|
54
|
+
if (!stateWritesEnabled()) return null;
|
|
54
55
|
try { fs.mkdirSync(dir, { recursive: true }); } catch {}
|
|
55
56
|
const safe = (s) => String(s || 'unknown').replace(/[^\w.-]/g, '-');
|
|
56
57
|
const fp = path.join(dir, `${safe(from)}-vs-${safe(to)}.json`);
|
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
import * as fs from 'node:fs';
|
|
28
28
|
import * as path from 'node:path';
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
|
|
31
|
+
import { statePath } from './state-dir.js';
|
|
31
32
|
const K8S_DIRS = ['k8s', 'infra/k8s', 'deploy/k8s', 'kubernetes', 'manifests'];
|
|
32
33
|
|
|
33
34
|
const INTERNAL_CIDRS = [
|
|
@@ -81,7 +82,7 @@ function parseKubeManifests(scanRoot) {
|
|
|
81
82
|
|
|
82
83
|
export function loadNetworkPosture(scanRoot) {
|
|
83
84
|
const root = scanRoot || process.cwd();
|
|
84
|
-
const digestPath =
|
|
85
|
+
const digestPath = statePath(root, 'network-policy.json');
|
|
85
86
|
if (fs.existsSync(digestPath)) {
|
|
86
87
|
try {
|
|
87
88
|
const data = JSON.parse(fs.readFileSync(digestPath, 'utf8'));
|
|
@@ -18,8 +18,7 @@
|
|
|
18
18
|
// suggestions; JSON-LD-shaped structured output is consumable by Vanta /
|
|
19
19
|
// Drata / SecureFrame or any custom rollup dashboard.
|
|
20
20
|
|
|
21
|
-
import
|
|
22
|
-
import * as path from 'node:path';
|
|
21
|
+
import { statePath, safeWriteState } from './state-dir.js';
|
|
23
22
|
|
|
24
23
|
function _byHndl(findings) {
|
|
25
24
|
return {
|
|
@@ -117,9 +116,12 @@ export function buildMigrationPlan(allFindings) {
|
|
|
117
116
|
|
|
118
117
|
export function persistMigrationPlan(scanRoot, plan) {
|
|
119
118
|
if (!plan) return null;
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
119
|
+
// Through the seam: creates the directory, enforces the project-root check,
|
|
120
|
+
// and honours the read-only scan switch. The plan is still RETURNED when
|
|
121
|
+
// writing is off — a scan must report the same result whether or not it is
|
|
122
|
+
// allowed to leave files behind.
|
|
123
|
+
safeWriteState(statePath(scanRoot, 'pqc-migration-plan.json'), JSON.stringify(plan, null, 2));
|
|
124
|
+
safeWriteState(statePath(scanRoot, 'pqc-migration-plan.md'), _markdown(plan));
|
|
123
125
|
return plan;
|
|
124
126
|
}
|
|
125
127
|
|
|
@@ -18,6 +18,7 @@ import * as fs from 'node:fs';
|
|
|
18
18
|
import * as path from 'node:path';
|
|
19
19
|
import { diffScans, summarizeDiff } from './baseline-compare.js';
|
|
20
20
|
|
|
21
|
+
import { statePath, safeWriteState } from './state-dir.js';
|
|
21
22
|
const REVIEWER_TRIGGERS = [
|
|
22
23
|
{ family: /^auth/, team: 'security', why: 'Auth-related findings' },
|
|
23
24
|
{ family: /^crypto/, team: 'security', why: 'Cryptography findings' },
|
|
@@ -29,7 +30,7 @@ const REVIEWER_TRIGGERS = [
|
|
|
29
30
|
];
|
|
30
31
|
|
|
31
32
|
function _stateFile(scanRoot, name) {
|
|
32
|
-
return
|
|
33
|
+
return statePath(scanRoot, name);
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
function _readJson(fp) {
|
|
@@ -38,7 +39,7 @@ function _readJson(fp) {
|
|
|
38
39
|
|
|
39
40
|
function _baselinePath(scanRoot, ref) {
|
|
40
41
|
const safe = String(ref || 'main').replace(/[^\w.-]/g, '-');
|
|
41
|
-
return
|
|
42
|
+
return statePath(scanRoot, 'scan-baselines', `${safe}.json`);
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
/**
|
|
@@ -47,8 +48,10 @@ function _baselinePath(scanRoot, ref) {
|
|
|
47
48
|
*/
|
|
48
49
|
export function persistBaseline(scanRoot, ref, scan) {
|
|
49
50
|
const fp = _baselinePath(scanRoot, ref);
|
|
50
|
-
|
|
51
|
-
|
|
51
|
+
// safeWriteState creates the directory, enforces the project-root check, and
|
|
52
|
+
// honours the read-only switch. The path is still returned either way, so
|
|
53
|
+
// callers that only report where the baseline WOULD live are unaffected.
|
|
54
|
+
safeWriteState(fp, JSON.stringify({ ref, ts: new Date().toISOString(), findings: scan.findings || [] }, null, 2));
|
|
52
55
|
return fp;
|
|
53
56
|
}
|
|
54
57
|
|
|
@@ -109,7 +112,7 @@ function _artifactLinks(scanRoot) {
|
|
|
109
112
|
];
|
|
110
113
|
const out = [];
|
|
111
114
|
for (const c of candidates) {
|
|
112
|
-
const fp =
|
|
115
|
+
const fp = statePath(scanRoot, c.file);
|
|
113
116
|
if (fs.existsSync(fp)) out.push({ name: c.name, path: `.agentic-security/${c.file}` });
|
|
114
117
|
}
|
|
115
118
|
return out;
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// NIST Privacy Framework 1.1 — assessment + remediation planning.
|
|
2
|
+
//
|
|
3
|
+
// The bundled control set (`compliance-frameworks/nist-privacy-1-1.json`) is
|
|
4
|
+
// rendered by `auditor-walkthrough.js` like every other framework. This module
|
|
5
|
+
// adds the half a walkthrough cannot give you: a gap becomes a FINDING carrying
|
|
6
|
+
// a concrete remediation, so it flows through the same triage and `/fix` path
|
|
7
|
+
// as any other finding rather than sitting in a narrative nobody actions.
|
|
8
|
+
//
|
|
9
|
+
// THE INTEGRITY RULE THIS MODULE EXISTS TO ENFORCE
|
|
10
|
+
// -----------------------------------------------
|
|
11
|
+
// NIST ships a `Code Testable?` rating per control — yes / partial / no. On the
|
|
12
|
+
// 104 controls: 23 yes, 33 partial, **48 no**.
|
|
13
|
+
//
|
|
14
|
+
// That rating says whether a control COULD be assessed from source code. It does
|
|
15
|
+
// NOT say this engine assesses it. Conflating the two is how a compliance tool
|
|
16
|
+
// starts reporting governance controls — "the organization's privacy values are
|
|
17
|
+
// communicated" — as PASSED because no scanner rule fired against them. Silence
|
|
18
|
+
// is not evidence, and a clean report over 48 controls nobody measured is worse
|
|
19
|
+
// than no report: it is a false assurance that someone will hand to an auditor.
|
|
20
|
+
//
|
|
21
|
+
// So every control lands in exactly one of four buckets, and the bucket is
|
|
22
|
+
// always stated:
|
|
23
|
+
//
|
|
24
|
+
// satisfied mapped to a real engine signal, and that signal is clean
|
|
25
|
+
// gap mapped to a real engine signal, and that signal is dirty
|
|
26
|
+
// → this is the only bucket that produces a finding
|
|
27
|
+
// engine-gap NIST rates it code-testable, but this engine has no signal
|
|
28
|
+
// → disclosed BY NAME, never counted as satisfied
|
|
29
|
+
// manual NIST rates it not code-testable (governance, policy, process)
|
|
30
|
+
// → out of scope for any scanner, stated as such
|
|
31
|
+
//
|
|
32
|
+
// `engine-gap` is the bucket that keeps this honest. It is the difference
|
|
33
|
+
// between "we checked and it is fine" and "nobody checked", and it is reported
|
|
34
|
+
// with the same prominence as a failure because to a reader relying on the
|
|
35
|
+
// output they are the same thing.
|
|
36
|
+
|
|
37
|
+
import { loadFramework, evaluateFramework } from './auditor-walkthrough.js';
|
|
38
|
+
import { statePath, safeWriteState } from './state-dir.js';
|
|
39
|
+
|
|
40
|
+
export const PRIVACY_FRAMEWORK_ID = 'nist-privacy-1-1';
|
|
41
|
+
|
|
42
|
+
/** Bucket names, in report order (worst first). */
|
|
43
|
+
export const BUCKETS = Object.freeze(['gap', 'engine-gap', 'manual', 'satisfied']);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Remediation per control family. Deliberately ACTIONABLE — a command or a
|
|
47
|
+
* concrete artifact — rather than a restatement of the control text, which is
|
|
48
|
+
* what a compliance report usually offers and what makes them ignorable.
|
|
49
|
+
*
|
|
50
|
+
* Keyed by the control-id prefix so a new control in an existing category
|
|
51
|
+
* inherits sensible guidance instead of silently getting none.
|
|
52
|
+
*/
|
|
53
|
+
const REMEDIATION = [
|
|
54
|
+
[/^PR\.DS-P1$/, 'Encrypt data at rest with an AEAD cipher. Replace ECB mode and static IVs; run `/fix --family crypto-weak-cipher` to apply the deterministic swaps.'],
|
|
55
|
+
[/^PR\.DS-P2$/, 'Enforce TLS 1.2+ and stop disabling certificate verification. `/fix --family crypto-tls-no-verify` patches the verify-off call sites.'],
|
|
56
|
+
[/^PR\.AA-P1$/, 'Remove hardcoded credentials and add authentication to unauthenticated routes. `/fix --rotate-secret` handles the secrets; auth gaps need a route-level decision.'],
|
|
57
|
+
[/^PR\.AA-P4$/, 'Sign and verify identity assertions with a modern algorithm — no `none` alg, no weak-hash HMAC.'],
|
|
58
|
+
[/^PR\.AA-05$/, 'Add object-level authorization on endpoints that read or mutate personal data. Review the IDOR and authz findings individually; these are not safely auto-patchable.'],
|
|
59
|
+
[/^PR\.PS-P1$/, 'Tighten IaC: drop privileged pod security contexts and over-permissive IAM policies.'],
|
|
60
|
+
[/^PR\.PS-P2$/, 'Upgrade dependencies carrying known advisories. `/fix --sca` proposes the version bumps.'],
|
|
61
|
+
[/^PR\.PS-P4$/, 'Resolve dependency-confusion exposure by pinning internal scopes to your private registry.'],
|
|
62
|
+
[/^PR\.IR-P1$/, 'Restrict network and cluster access — no cluster-admin bindings, no wildcard IAM.'],
|
|
63
|
+
[/^PR\.DS-P8$/, 'Record an SBOM snapshot per release so dependency substitution is detectable.'],
|
|
64
|
+
[/^CT\.DP-P/, 'Reduce what is observable: minimise PII reaching logs, responses, and third parties; de-identify where the data action does not need identity.'],
|
|
65
|
+
[/^CT\.DM-P6$/, 'Transmit over TLS with a standard, current cipher suite.'],
|
|
66
|
+
[/^CT\.DM-P9$/, 'Retain scan history and the MCP audit log so data-processing activity is reviewable.'],
|
|
67
|
+
[/^CT\.DM-P10$/, 'Keep fix history so the technical measures you applied are demonstrably tested, not asserted.'],
|
|
68
|
+
[/^CT\.PO-P/, 'Declare the policy in `.agentic-security/compliance.policy.yml` so it is verified on every scan rather than remembered.'],
|
|
69
|
+
[/^CM\.AW-P4$/, 'Preserve the audit log — it is the record of disclosure this control asks for.'],
|
|
70
|
+
[/^CM\.AW-P6$/, 'Keep SBOM snapshots and signed scan state so provenance and lineage survive review.'],
|
|
71
|
+
[/^ID\.IM-P/, 'Produce the inventory artifacts: run a scan so the SBOM, threat model, and PII field map exist and are current.'],
|
|
72
|
+
[/^ID\.RA-P3$/, 'Triage the PII-exposure findings — each is a problematic data action this control asks you to identify.'],
|
|
73
|
+
[/^ID\.BE-P3$/, 'Generate the threat model so priority systems and their requirements are recorded.'],
|
|
74
|
+
[/^GV\.PO-P2$/, 'Commit a compliance policy file so privacy expectations are enforced in CI, not just documented.'],
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
/** The remediation for a control, or null when none is defined. */
|
|
78
|
+
export function remediationFor(controlId) {
|
|
79
|
+
for (const [re, text] of REMEDIATION) if (re.test(controlId)) return text;
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Bucket one evaluated control.
|
|
85
|
+
*
|
|
86
|
+
* `evaluateFramework` returns 'present' | 'partial' | 'manual'. That vocabulary
|
|
87
|
+
* cannot distinguish "no mapping because it is a governance control" from "no
|
|
88
|
+
* mapping because we never built the check" — which is exactly the distinction
|
|
89
|
+
* that decides whether a reader should worry. `codeTestable` supplies it.
|
|
90
|
+
*/
|
|
91
|
+
export function bucketOf(result, { assessable = true } = {}) {
|
|
92
|
+
const c = result.control || {};
|
|
93
|
+
const mapped = Array.isArray(c.mapsTo) && c.mapsTo.length > 0;
|
|
94
|
+
if (mapped) {
|
|
95
|
+
// VACUOUS SATISFACTION GUARD. `evaluateFramework` clears a `family:`
|
|
96
|
+
// mapping when no findings of that family are open — which is also true of
|
|
97
|
+
// a scan that examined nothing at all. Without this, pointing the tool at
|
|
98
|
+
// an empty directory reports privacy controls as SATISFIED, on the strength
|
|
99
|
+
// of having looked at zero files. That is the same false assurance the
|
|
100
|
+
// manual bucket exists to prevent, arriving by a different route, and it
|
|
101
|
+
// was caught by this module's own test rather than in review.
|
|
102
|
+
if (!assessable) return 'engine-gap';
|
|
103
|
+
return result.status === 'present' ? 'satisfied' : 'gap';
|
|
104
|
+
}
|
|
105
|
+
return String(c.codeTestable || 'no').toLowerCase() === 'no' ? 'manual' : 'engine-gap';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Severity for a gap. Privacy controls are graded by what the gap exposes. */
|
|
109
|
+
function severityFor(controlId) {
|
|
110
|
+
if (/^PR\.(DS|AA)/.test(controlId)) return 'high'; // encryption, identity, access
|
|
111
|
+
if (/^CT\.DP/.test(controlId)) return 'medium'; // data minimisation
|
|
112
|
+
return 'low';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Assess a scan against NIST Privacy Framework 1.1.
|
|
117
|
+
*
|
|
118
|
+
* Returns `{ frameworkId, controls[], summary, findings[] }`. Never throws:
|
|
119
|
+
* posture modules degrade to a null result rather than failing a scan.
|
|
120
|
+
*/
|
|
121
|
+
export function assessPrivacyFramework(scanRoot, scan, opts = {}) {
|
|
122
|
+
const fw = loadFramework(scanRoot, PRIVACY_FRAMEWORK_ID);
|
|
123
|
+
if (!fw) return null;
|
|
124
|
+
|
|
125
|
+
let evaluation;
|
|
126
|
+
try { evaluation = evaluateFramework(scanRoot, fw, scan || {}); } catch { return null; }
|
|
127
|
+
|
|
128
|
+
// Did the scan actually examine anything? A clean signal from a run that read
|
|
129
|
+
// no files is not evidence — see the guard in bucketOf.
|
|
130
|
+
const filesScanned = Number(scan && (scan.filesScanned ?? scan._scanMeta?.filesScanned)) || 0;
|
|
131
|
+
const assessable = filesScanned > 0
|
|
132
|
+
|| (Array.isArray(scan?.findings) && scan.findings.length > 0)
|
|
133
|
+
|| (Array.isArray(scan?.components) && scan.components.length > 0);
|
|
134
|
+
|
|
135
|
+
const controls = [];
|
|
136
|
+
const findings = [];
|
|
137
|
+
const summary = { gap: 0, 'engine-gap': 0, manual: 0, satisfied: 0, total: 0 };
|
|
138
|
+
|
|
139
|
+
for (const r of evaluation) {
|
|
140
|
+
const c = r.control || {};
|
|
141
|
+
const bucket = bucketOf(r, { assessable });
|
|
142
|
+
summary[bucket] += 1;
|
|
143
|
+
summary.total += 1;
|
|
144
|
+
|
|
145
|
+
const row = {
|
|
146
|
+
id: c.id,
|
|
147
|
+
function: c.function || null,
|
|
148
|
+
category: c.category || null,
|
|
149
|
+
summary: c.summary || '',
|
|
150
|
+
codeTestable: c.codeTestable || 'no',
|
|
151
|
+
bucket,
|
|
152
|
+
observations: r.observations || [],
|
|
153
|
+
};
|
|
154
|
+
if (bucket === 'engine-gap') {
|
|
155
|
+
// Named, not counted as a pass. See the header.
|
|
156
|
+
row.disclosure = (Array.isArray(c.mapsTo) && c.mapsTo.length && !assessable)
|
|
157
|
+
? 'This control is mapped, but the scan examined no files — a clean signal from a run that read nothing is not evidence. NOT assessed.'
|
|
158
|
+
: `NIST rates this control code-testable (${row.codeTestable}), but this engine has no signal for it. It was NOT assessed.`;
|
|
159
|
+
}
|
|
160
|
+
if (bucket === 'manual') {
|
|
161
|
+
row.disclosure = 'NIST rates this control not code-testable — it is a governance, policy, or process control and is outside any scanner\'s reach.';
|
|
162
|
+
}
|
|
163
|
+
controls.push(row);
|
|
164
|
+
|
|
165
|
+
if (bucket !== 'gap') continue;
|
|
166
|
+
const remediation = remediationFor(c.id)
|
|
167
|
+
|| 'Review the observations for this control and close the underlying findings.';
|
|
168
|
+
findings.push({
|
|
169
|
+
id: `privacy-framework:${c.id}`,
|
|
170
|
+
severity: severityFor(c.id),
|
|
171
|
+
// A compliance gap is a property of the project, not of one line. The
|
|
172
|
+
// framework file is cited so the finding still has a real, openable
|
|
173
|
+
// location rather than a fabricated one.
|
|
174
|
+
file: '.agentic-security/compliance/nist-privacy-1-1',
|
|
175
|
+
line: 0,
|
|
176
|
+
vuln: `NIST Privacy Framework ${c.id} not satisfied — ${c.summary}`,
|
|
177
|
+
cwe: 'CWE-359',
|
|
178
|
+
description: [
|
|
179
|
+
`Control ${c.id} (${c.category || c.function || 'privacy'}) is mapped to engine signals that are currently failing.`,
|
|
180
|
+
...(r.observations || []),
|
|
181
|
+
].join(' '),
|
|
182
|
+
remediation,
|
|
183
|
+
parser: 'COMPLIANCE',
|
|
184
|
+
family: 'privacy-compliance',
|
|
185
|
+
complianceControl: { framework: PRIVACY_FRAMEWORK_ID, id: c.id, codeTestable: c.codeTestable || 'no' },
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
frameworkId: PRIVACY_FRAMEWORK_ID,
|
|
191
|
+
frameworkName: fw.name,
|
|
192
|
+
controls,
|
|
193
|
+
summary,
|
|
194
|
+
findings,
|
|
195
|
+
// Carried in the result so a consumer cannot present the numbers without
|
|
196
|
+
// it. `satisfied` is a share of the ASSESSED controls, never of all 104.
|
|
197
|
+
assessable,
|
|
198
|
+
interpretation:
|
|
199
|
+
(!assessable ? 'The scan examined no files, so NO control was assessed. ' : '') +
|
|
200
|
+
`${summary.satisfied} of ${summary.satisfied + summary.gap} assessed controls satisfied. ` +
|
|
201
|
+
`${summary['engine-gap']} controls NIST rates code-testable were NOT assessed by this engine, and ` +
|
|
202
|
+
`${summary.manual} are governance controls outside any scanner's reach. ` +
|
|
203
|
+
'Neither group is evidence of compliance.',
|
|
204
|
+
...(opts.includeFindings === false ? { findings: [] } : {}),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Render the assessment as Markdown, worst bucket first.
|
|
210
|
+
*
|
|
211
|
+
* Order is deliberate: gaps, then the controls nobody assessed, then governance,
|
|
212
|
+
* then satisfied. A report that opens with what passed invites the reader to
|
|
213
|
+
* stop there, which is precisely the wrong reading of a document where most
|
|
214
|
+
* controls carry no engine evidence at all.
|
|
215
|
+
*/
|
|
216
|
+
function renderPrivacyMarkdown(result) {
|
|
217
|
+
if (!result) return '';
|
|
218
|
+
const L = [];
|
|
219
|
+
L.push(`# ${result.frameworkName} — assessment`);
|
|
220
|
+
L.push('');
|
|
221
|
+
L.push(result.interpretation);
|
|
222
|
+
L.push('');
|
|
223
|
+
L.push('| Bucket | Controls | Meaning |');
|
|
224
|
+
L.push('| --- | --- | --- |');
|
|
225
|
+
L.push(`| Gap | ${result.summary.gap} | Mapped to an engine signal, and that signal is failing |`);
|
|
226
|
+
L.push(`| Not assessed | ${result.summary['engine-gap']} | Code-testable, but this engine has no signal — **not** a pass |`);
|
|
227
|
+
L.push(`| Manual | ${result.summary.manual} | Governance/policy control, outside any scanner's reach |`);
|
|
228
|
+
L.push(`| Satisfied | ${result.summary.satisfied} | Mapped, and the signal is clean |`);
|
|
229
|
+
L.push('');
|
|
230
|
+
L.push('> A control in *Not assessed* or *Manual* is not evidence of compliance.');
|
|
231
|
+
L.push('> This document organizes scanner evidence; it does not certify anything.');
|
|
232
|
+
L.push('> A licensed assessor is responsible for the attestation.');
|
|
233
|
+
L.push('');
|
|
234
|
+
for (const bucket of BUCKETS) {
|
|
235
|
+
const rows = result.controls.filter(c => c.bucket === bucket);
|
|
236
|
+
if (!rows.length) continue;
|
|
237
|
+
L.push(`## ${bucket} (${rows.length})`);
|
|
238
|
+
L.push('');
|
|
239
|
+
for (const c of rows) {
|
|
240
|
+
L.push(`### ${c.id} — ${c.summary}`);
|
|
241
|
+
L.push(`- NIST code-testable: **${c.codeTestable}**`);
|
|
242
|
+
if (c.disclosure) L.push(`- ${c.disclosure}`);
|
|
243
|
+
for (const o of c.observations || []) L.push(`- ${o}`);
|
|
244
|
+
const rem = bucket === 'gap' ? remediationFor(c.id) : null;
|
|
245
|
+
if (rem) L.push(`- **Remediation:** ${rem}`);
|
|
246
|
+
L.push('');
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return L.join('\n');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Persist the assessment. Through the seam, so a read-only scan writes nothing
|
|
254
|
+
* while still RETURNING the assessment — the switch changes what is written,
|
|
255
|
+
* never what is reported.
|
|
256
|
+
*/
|
|
257
|
+
export function persistPrivacyFramework(scanRoot, result) {
|
|
258
|
+
if (!result) return null;
|
|
259
|
+
safeWriteState(statePath(scanRoot, 'privacy-framework.json'), JSON.stringify(result, null, 2));
|
|
260
|
+
safeWriteState(statePath(scanRoot, 'privacy-framework.md'), renderPrivacyMarkdown(result));
|
|
261
|
+
return result;
|
|
262
|
+
}
|
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
import * as fs from 'node:fs';
|
|
24
24
|
import * as path from 'node:path';
|
|
25
25
|
|
|
26
|
-
const STATE = '.agentic-security';
|
|
27
26
|
|
|
27
|
+
import { statePath } from './state-dir.js';
|
|
28
28
|
// Base rates per family (annual probability of at-least-one exploit given
|
|
29
29
|
// an exposed instance). Rough industry estimates; tune via config.
|
|
30
30
|
const FAMILY_BASE_PROB = {
|
|
@@ -79,7 +79,7 @@ const REACH_DISCOUNT = {
|
|
|
79
79
|
};
|
|
80
80
|
|
|
81
81
|
function _loadConfig(scanRoot) {
|
|
82
|
-
const fp =
|
|
82
|
+
const fp = statePath(scanRoot, 'risk-config.yml');
|
|
83
83
|
if (!fs.existsSync(fp)) return null;
|
|
84
84
|
try {
|
|
85
85
|
const body = fs.readFileSync(fp, 'utf8');
|
package/src/posture/router.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import * as fs from 'node:fs';
|
|
18
18
|
import * as path from 'node:path';
|
|
19
19
|
|
|
20
|
+
import { stateDir, statePath } from './state-dir.js';
|
|
20
21
|
function readJson(fp) {
|
|
21
22
|
if (!fs.existsSync(fp)) return null;
|
|
22
23
|
try { return JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { return null; }
|
|
@@ -32,7 +33,7 @@ function ageHours(fp) {
|
|
|
32
33
|
// history to say anything honest. (premortem: never invent a trend from one
|
|
33
34
|
// data point.)
|
|
34
35
|
export function computeScanTrend(scanRoot) {
|
|
35
|
-
const hist = readJson(
|
|
36
|
+
const hist = readJson(statePath(scanRoot, 'scan-history.json'));
|
|
36
37
|
if (!Array.isArray(hist) || hist.length < 2) return {};
|
|
37
38
|
const cur = hist[hist.length - 1];
|
|
38
39
|
const prev = hist[hist.length - 2];
|
|
@@ -55,9 +56,9 @@ export function decide(opts) {
|
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
function baseDecision({ scanRoot, intent }) {
|
|
58
|
-
const
|
|
59
|
-
const lastScan = readJson(path.join(
|
|
60
|
-
const scanAge = ageHours(path.join(
|
|
59
|
+
const stateDirPath = stateDir(scanRoot);
|
|
60
|
+
const lastScan = readJson(path.join(stateDirPath, 'last-scan.json'));
|
|
61
|
+
const scanAge = ageHours(path.join(stateDirPath, 'last-scan.json'));
|
|
61
62
|
|
|
62
63
|
if (!lastScan) {
|
|
63
64
|
return {
|
|
@@ -25,14 +25,14 @@ import * as fs from 'node:fs';
|
|
|
25
25
|
import * as path from 'node:path';
|
|
26
26
|
import { SCANNER_VERSION } from './version.js';
|
|
27
27
|
|
|
28
|
+
import { statePath } from './state-dir.js';
|
|
28
29
|
// Tied to scanner/package.json via posture/version.js — they cannot diverge
|
|
29
30
|
// (premortem 3R1.3).
|
|
30
31
|
export const CURRENT_RULESET_VERSION = SCANNER_VERSION;
|
|
31
32
|
|
|
32
|
-
const FILE = '.agentic-security/ruleset-version.json';
|
|
33
33
|
|
|
34
34
|
export function readPinned(scanRoot) {
|
|
35
|
-
const fp =
|
|
35
|
+
const fp = statePath(scanRoot || process.cwd(), 'ruleset-version.json');
|
|
36
36
|
if (!fs.existsSync(fp)) return null;
|
|
37
37
|
try { return JSON.parse(fs.readFileSync(fp, 'utf8')); }
|
|
38
38
|
catch { return null; }
|
|
@@ -35,12 +35,13 @@ import * as path from 'node:path';
|
|
|
35
35
|
import * as readline from 'node:readline';
|
|
36
36
|
import { createReadStream } from 'node:fs';
|
|
37
37
|
|
|
38
|
+
import { statePath } from './state-dir.js';
|
|
38
39
|
const DEFAULT_TRACE_NAMES = ['runtime-trace.jsonl', 'runtime.jsonl', 'ebpf-trace.jsonl'];
|
|
39
40
|
const DEFAULT_OBSERVATION_WINDOW_DAYS = 30;
|
|
40
41
|
|
|
41
42
|
export async function loadTrace(scanRoot, opts = {}) {
|
|
42
43
|
const explicit = opts.tracePath || process.env.AGENTIC_SECURITY_RUNTIME_TRACE_PATH;
|
|
43
|
-
const candidates = explicit ? [explicit] : DEFAULT_TRACE_NAMES.map(n =>
|
|
44
|
+
const candidates = explicit ? [explicit] : DEFAULT_TRACE_NAMES.map(n => statePath(scanRoot, n));
|
|
44
45
|
let chosen = null;
|
|
45
46
|
for (const c of candidates) {
|
|
46
47
|
if (fs.existsSync(c)) { chosen = c; break; }
|
package/src/posture/sbom-diff.js
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
// module produces the diff on the next scan.
|
|
22
22
|
|
|
23
23
|
import * as fs from 'node:fs';
|
|
24
|
+
import { statePath, stateWritesEnabled } from './state-dir.js';
|
|
24
25
|
import * as path from 'node:path';
|
|
25
26
|
import * as crypto from 'node:crypto';
|
|
26
27
|
import { execSync } from 'node:child_process';
|
|
@@ -28,7 +29,7 @@ import { execSync } from 'node:child_process';
|
|
|
28
29
|
const HISTORY_DIR = 'sbom-history';
|
|
29
30
|
|
|
30
31
|
function _historyDir(scanRoot) {
|
|
31
|
-
return
|
|
32
|
+
return statePath(scanRoot, HISTORY_DIR);
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
function _gitHead(scanRoot) {
|
|
@@ -47,7 +48,11 @@ function _snapshotKey(component) {
|
|
|
47
48
|
*/
|
|
48
49
|
export function persistSbom(scanRoot, components) {
|
|
49
50
|
const dir = _historyDir(scanRoot);
|
|
50
|
-
|
|
51
|
+
// The mkdir is inside the switch too. Guarding only the write still left an
|
|
52
|
+
// empty `sbom-history/` in the scanned tree — invisible to `git status`,
|
|
53
|
+
// because git does not track empty directories, and therefore exactly the
|
|
54
|
+
// kind of mutation that passes a clean-status check while still being one.
|
|
55
|
+
if (stateWritesEnabled()) { try { fs.mkdirSync(dir, { recursive: true }); } catch {} }
|
|
51
56
|
const sha = _gitHead(scanRoot) || crypto.createHash('sha256').update(JSON.stringify(components)).digest('hex').slice(0, 12);
|
|
52
57
|
const snap = {
|
|
53
58
|
sha, ts: new Date().toISOString(),
|
|
@@ -58,7 +63,11 @@ export function persistSbom(scanRoot, components) {
|
|
|
58
63
|
sha256: c.sha256 || c.integrity || null,
|
|
59
64
|
})),
|
|
60
65
|
};
|
|
61
|
-
|
|
66
|
+
// NON_MUTATING_SCAN_PRD S1 — history is a feature, but not at the cost of
|
|
67
|
+
// mutating a tree the caller only asked us to read.
|
|
68
|
+
if (stateWritesEnabled()) {
|
|
69
|
+
try { fs.writeFileSync(path.join(dir, `${sha}.json`), JSON.stringify(snap, null, 2)); } catch {}
|
|
70
|
+
}
|
|
62
71
|
return snap;
|
|
63
72
|
}
|
|
64
73
|
|
|
@@ -40,6 +40,7 @@ import * as fs from 'node:fs';
|
|
|
40
40
|
import * as path from 'node:path';
|
|
41
41
|
import * as yaml from '../util/yaml.js';
|
|
42
42
|
|
|
43
|
+
import { safeWriteState, stateDir, statePath } from './state-dir.js';
|
|
43
44
|
const DEFAULT_POLICY = {
|
|
44
45
|
acceptRisk: [],
|
|
45
46
|
sla: {},
|
|
@@ -49,7 +50,7 @@ const DEFAULT_POLICY = {
|
|
|
49
50
|
export function loadScaPolicy(scanRoot) {
|
|
50
51
|
if (!scanRoot) return null;
|
|
51
52
|
for (const name of ['sca-policy.yml', 'sca-policy.yaml', 'sca-policy.json']) {
|
|
52
|
-
const p =
|
|
53
|
+
const p = statePath(scanRoot, name);
|
|
53
54
|
if (!fs.existsSync(p)) continue;
|
|
54
55
|
try {
|
|
55
56
|
const raw = fs.readFileSync(p, 'utf8');
|
|
@@ -183,7 +184,7 @@ export function applyScaPolicy(findings, policy, scanTime = new Date()) {
|
|
|
183
184
|
// If the policy file doesn't exist, one is created with safe defaults.
|
|
184
185
|
export function appendAcceptRiskFromTriage(scanRoot, finding, reason) {
|
|
185
186
|
if (!scanRoot || !finding) return { ok: false, reason: 'missing arguments' };
|
|
186
|
-
const dir =
|
|
187
|
+
const dir = stateDir(scanRoot);
|
|
187
188
|
const fp = path.join(dir, 'sca-policy.yml');
|
|
188
189
|
let policy = loadScaPolicy(scanRoot);
|
|
189
190
|
if (policy && policy._error) return { ok: false, reason: policy._error };
|
|
@@ -209,7 +210,6 @@ export function appendAcceptRiskFromTriage(scanRoot, finding, reason) {
|
|
|
209
210
|
};
|
|
210
211
|
policy.acceptRisk.push(newEntry);
|
|
211
212
|
|
|
212
|
-
try { fs.mkdirSync(dir, { recursive: true }); } catch {}
|
|
213
213
|
const serialized = yaml.dump({
|
|
214
214
|
'accept-risk': policy.acceptRisk.map(e => {
|
|
215
215
|
const o = {};
|
|
@@ -224,7 +224,10 @@ export function appendAcceptRiskFromTriage(scanRoot, finding, reason) {
|
|
|
224
224
|
sla: policy.sla && Object.keys(policy.sla).length ? Object.fromEntries(Object.entries(policy.sla).map(([k, v]) => [k, _formatSlaDuration(v)])) : undefined,
|
|
225
225
|
'major-version-freeze': policy.majorVersionFreeze && Object.keys(policy.majorVersionFreeze).length ? policy.majorVersionFreeze : undefined,
|
|
226
226
|
});
|
|
227
|
-
|
|
227
|
+
// Through the seam. This one is user-initiated (a triage decision), so a
|
|
228
|
+
// refusal is reported rather than swallowed: silently not recording an
|
|
229
|
+
// accepted risk would be worse than failing loudly. (PRD M1)
|
|
230
|
+
if (!safeWriteState(fp, serialized)) return { ok: false, reason: 'state writes disabled or path outside a project root', path: fp };
|
|
228
231
|
return { ok: true, entry: newEntry, path: fp };
|
|
229
232
|
}
|
|
230
233
|
|
|
@@ -43,6 +43,15 @@ import * as path from 'node:path';
|
|
|
43
43
|
import * as crypto from 'node:crypto';
|
|
44
44
|
import { fileURLToPath } from 'node:url';
|
|
45
45
|
|
|
46
|
+
import { stateWritesEnabled } from './state-dir.js';
|
|
47
|
+
|
|
48
|
+
// DELIBERATELY NOT routed through statePath(). `resolveProjectRoot()` falls
|
|
49
|
+
// back to walking upward from process.cwd() when the given root does not exist
|
|
50
|
+
// on disk, so `checkpointPath('/some/root')` resolved into the SCANNER'S OWN
|
|
51
|
+
// SOURCE TREE — caught by the test that asserts a checkpoint is never written
|
|
52
|
+
// there. A checkpoint must land in the root it was handed or nowhere; silently
|
|
53
|
+
// relocating it into another directory is the exact failure this line of work
|
|
54
|
+
// exists to prevent. The read-only switch above is still honoured.
|
|
46
55
|
const STATE_DIR = '.agentic-security';
|
|
47
56
|
const FILE_NAME = 'scan-checkpoint.jsonl';
|
|
48
57
|
const FORMAT = 'agentic-security-scan-checkpoint/1';
|
|
@@ -191,6 +200,12 @@ function _recover(handle, file, runKey) {
|
|
|
191
200
|
*/
|
|
192
201
|
export function openCheckpoint(scanRoot, { runKey } = {}) {
|
|
193
202
|
if (!scanRoot || !runKey) return _emptyHandle('no-run-key');
|
|
203
|
+
// A read-only scan cannot checkpoint, and must not try. Resume is purely an
|
|
204
|
+
// optimisation — without it the scan recomputes, which is slower and
|
|
205
|
+
// identical — so `--no-state` wins over `AGENTIC_SECURITY_RESUME=1` rather
|
|
206
|
+
// than the two conflicting. The disabled handle no-ops through the rest of
|
|
207
|
+
// the API, so no caller needs a new branch. (PRD M1)
|
|
208
|
+
if (!stateWritesEnabled()) return _emptyHandle('state-writes-disabled');
|
|
194
209
|
const handle = _emptyHandle(null);
|
|
195
210
|
try {
|
|
196
211
|
const dir = path.join(scanRoot, STATE_DIR);
|
package/src/posture/state-dir.js
CHANGED
|
@@ -111,9 +111,42 @@ export function isSafeStateDir(dir) {
|
|
|
111
111
|
return false;
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
// --- Read-only scanning (NON_MUTATING_SCAN_PRD S1) ---------------------------
|
|
115
|
+
//
|
|
116
|
+
// A scan is an OBSERVATION. Pointing the engine at a directory should not
|
|
117
|
+
// change it. Until this switch existed, `scan .` wrote seven-plus files into the
|
|
118
|
+
// scanned tree, which breaks CI that asserts a clean worktree, leaves artefacts
|
|
119
|
+
// in third-party code the user does not own, and — measured on this project's
|
|
120
|
+
// own benchmark — let a second scan read the first scan's conclusions as source.
|
|
121
|
+
//
|
|
122
|
+
// This is deliberately a KILL SWITCH AT THE SEAM rather than a parameter
|
|
123
|
+
// threaded through 72 call sites. Threading it would mean 72 chances to forget,
|
|
124
|
+
// and the module this lives in exists precisely because that kind of
|
|
125
|
+
// per-caller discipline already failed once: its header records a user who
|
|
126
|
+
// uninstalled the plugin after stray state directories broke their build.
|
|
127
|
+
let _stateWritesEnabled = true;
|
|
128
|
+
|
|
129
|
+
/** Turn all state writing off (or back on) for this process. */
|
|
130
|
+
export function setStateWritesEnabled(enabled) {
|
|
131
|
+
_stateWritesEnabled = Boolean(enabled);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* False when writing is disabled by the CLI flag or the environment.
|
|
136
|
+
*
|
|
137
|
+
* The env var is read at CALL time, not captured at import, so a test or a
|
|
138
|
+
* caller can set it after the module is loaded — the same mistake that made the
|
|
139
|
+
* gate verdict cache silently never engage.
|
|
140
|
+
*/
|
|
141
|
+
export function stateWritesEnabled() {
|
|
142
|
+
if (process.env.AGENTIC_SECURITY_NO_STATE === '1') return false;
|
|
143
|
+
return _stateWritesEnabled;
|
|
144
|
+
}
|
|
145
|
+
|
|
114
146
|
// Safe mkdir: only creates .agentic-security/ if the parent has a project marker.
|
|
115
147
|
// Returns the dir on success, null if refused. Logs a warning when refused.
|
|
116
148
|
export function ensureStateDir(scanRoot) {
|
|
149
|
+
if (!stateWritesEnabled()) return null;
|
|
117
150
|
const dir = stateDir(scanRoot);
|
|
118
151
|
if (!isSafeStateDir(dir)) {
|
|
119
152
|
if (process.env.AGENTIC_SECURITY_DEBUG === '1') {
|
|
@@ -132,6 +165,7 @@ export function ensureStateDir(scanRoot) {
|
|
|
132
165
|
// Safe write: only writes if isSafeStateDir(parent) returns true.
|
|
133
166
|
// Returns true on success, false if refused or errored.
|
|
134
167
|
export function safeWriteState(filePath, content) {
|
|
168
|
+
if (!stateWritesEnabled()) return false;
|
|
135
169
|
const dir = path.dirname(filePath);
|
|
136
170
|
if (!isSafeStateDir(dir)) {
|
|
137
171
|
if (process.env.AGENTIC_SECURITY_DEBUG === '1') {
|