@clear-capabilities/agentic-security-scanner 0.144.0 → 0.145.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/CHANGELOG.md +251 -0
- package/bin/agentic-security.js +294 -3
- package/dist/113.index.js +11 -3
- package/dist/178.index.js +24 -6
- package/dist/271.index.js +165 -0
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +22 -0
- package/dist/444.index.js +11 -2
- package/dist/449.index.js +76 -12
- package/dist/526.index.js +11 -3
- package/dist/637.index.js +27 -5
- package/dist/970.index.js +65 -1
- package/dist/agentic-security.mjs +9 -9
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +14 -8
- package/src/compare.js +6 -1
- package/src/dataflow/CLAUDE.md +1 -1
- package/src/engine.js +488 -29
- package/src/fix/apply-fix-service.js +1 -0
- package/src/history-scan.js +22 -5
- package/src/ir/CLAUDE.md +1 -1
- package/src/lsp/server.js +49 -2
- package/src/mcp/tools.js +20 -0
- package/src/pipeline/assurance-mode.js +64 -1
- package/src/pipeline/finding-schema.js +8 -1
- package/src/posture/CLAUDE.md +121 -0
- package/src/posture/accuracy-scorecard.js +60 -0
- package/src/posture/artifact-registry.js +24 -0
- package/src/posture/auditor-walkthrough.js +116 -13
- package/src/posture/compliance-policy.js +12 -2
- package/src/posture/cross-repo-memory.js +7 -2
- package/src/posture/fix-history.js +25 -2
- package/src/posture/fix-verify.js +9 -1
- package/src/posture/fleet.js +0 -0
- package/src/posture/git-history.js +13 -5
- package/src/posture/material-change.js +21 -2
- package/src/posture/mttr.js +75 -12
- package/src/posture/pre-incident-archaeology.js +39 -7
- package/src/posture/privacy-framework.js +14 -0
- package/src/posture/provenance/ai-authorship.js +68 -0
- package/src/posture/provenance/branch-entry.js +80 -0
- package/src/posture/provenance/cache.js +143 -0
- package/src/posture/provenance/confidence.js +36 -0
- package/src/posture/provenance/coordinator.js +786 -0
- package/src/posture/provenance/dag-walk.js +249 -0
- package/src/posture/provenance/evidence-attribution.js +59 -0
- package/src/posture/provenance/git-evidence.js +310 -0
- package/src/posture/provenance/lifecycle.js +208 -0
- package/src/posture/provenance/missing-control-resolver.js +137 -0
- package/src/posture/provenance/origin-resolver.js +342 -0
- package/src/posture/provenance/predicate-replay.js +133 -0
- package/src/posture/provenance/providers/config.js +39 -0
- package/src/posture/provenance/providers/github.js +62 -0
- package/src/posture/provenance/providers/gitlab.js +58 -0
- package/src/posture/provenance/repo-lineage.js +74 -0
- package/src/posture/provenance/sca-origin.js +139 -0
- package/src/posture/provenance/schema.js +255 -0
- package/src/posture/provenance/transitive-sca.js +147 -0
- package/src/posture/provenance/validate.js +30 -0
- package/src/posture/provenance-evidence-bundle.js +144 -0
- package/src/posture/sbom-diff.js +15 -2
- package/src/posture/secret-history.js +10 -2
- package/src/posture/state-dir.js +38 -14
- package/src/posture/vuln-archaeology.js +8 -2
- package/src/pr-delta.js +25 -4
- package/src/report/index.js +197 -3
- package/src/runScan.js +34 -5
- package/src/sast/rate-limit.js +33 -3
- package/src/util/git-hardening.js +128 -0
|
@@ -12,8 +12,10 @@
|
|
|
12
12
|
// introducingCommit: { sha, author, ts, message } | null,
|
|
13
13
|
// }
|
|
14
14
|
//
|
|
15
|
-
// We invoke `git log` and `git show` via
|
|
16
|
-
//
|
|
15
|
+
// We invoke `git log` and `git show` via `execFileSync` (an argv array, NOT
|
|
16
|
+
// a shell) against `root` — the SCANNED project's repository, not this
|
|
17
|
+
// project's own trusted checkout. If the project is not a git repository
|
|
18
|
+
// (no `.git` at root), we return `{ available: false }`.
|
|
17
19
|
//
|
|
18
20
|
// This is intentionally light: we do not re-run the SAST detector on every
|
|
19
21
|
// historical revision. We use a simple substring-presence probe — does the
|
|
@@ -21,10 +23,30 @@
|
|
|
21
23
|
// Sufficient for the common case and dramatically cheaper than full
|
|
22
24
|
// re-scanning. Customers who want forensic-grade archaeology can re-run the
|
|
23
25
|
// scanner against `git checkout`-ed historical revisions.
|
|
26
|
+
//
|
|
27
|
+
// Hardened + de-shelled per FR-PROV-024 / the second Finding Provenance PRD
|
|
28
|
+
// audit sweep (found missing here by a follow-up review that grepped for
|
|
29
|
+
// `child_process` usage beyond just `execFileSync('git'` call sites). Before
|
|
30
|
+
// this fix, `gitShow` built a SHELL command string
|
|
31
|
+
// (`` `git show ${sha}:"${file}"` ``) from `file`, which is a finding's
|
|
32
|
+
// repo-relative path — attacker-controlled in a hostile repo. Double quotes
|
|
33
|
+
// in `sh` do NOT suppress `$(...)`/backticks, so a repo containing a file
|
|
34
|
+
// literally named `a$(cmd).js` would execute `cmd` via command substitution
|
|
35
|
+
// the moment this ran. Switching to `execFileSync` with an argv array
|
|
36
|
+
// removes the shell entirely — there is no interpreter left to interpret
|
|
37
|
+
// `$(...)`, so this is not "quote it better," it closes the injection class
|
|
38
|
+
// outright. `sha` here always originates from THIS module's own
|
|
39
|
+
// `gitLogForFile` (`%H`, real git-produced hex), but is still validated
|
|
40
|
+
// against the same SHA_RE every other resolver in this codebase uses, on
|
|
41
|
+
// the same "never trust, always validate" convention as
|
|
42
|
+
// provenance/git-evidence.js.
|
|
24
43
|
|
|
25
|
-
import {
|
|
44
|
+
import { execFileSync } from 'node:child_process';
|
|
26
45
|
import * as fs from 'node:fs';
|
|
27
46
|
import * as path from 'node:path';
|
|
47
|
+
import { hardenGitArgs, hardenGitEnv } from '../util/git-hardening.js';
|
|
48
|
+
|
|
49
|
+
const SHA_RE = /^[0-9a-f]{4,40}$/i;
|
|
28
50
|
|
|
29
51
|
function isGitRepo(root) {
|
|
30
52
|
try {
|
|
@@ -34,9 +56,10 @@ function isGitRepo(root) {
|
|
|
34
56
|
|
|
35
57
|
function gitLogForFile(root, file, limit = 50) {
|
|
36
58
|
try {
|
|
37
|
-
const out =
|
|
38
|
-
|
|
39
|
-
|
|
59
|
+
const out = execFileSync(
|
|
60
|
+
'git',
|
|
61
|
+
hardenGitArgs(['log', '--pretty=format:%H%x1f%an%x1f%aI%x1f%s', `--max-count=${limit}`, '--', file]),
|
|
62
|
+
{ cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: hardenGitEnv() },
|
|
40
63
|
);
|
|
41
64
|
return out.split(/\n/).filter(Boolean).map(line => {
|
|
42
65
|
const [sha, author, ts, message] = line.split('\x1f');
|
|
@@ -46,8 +69,17 @@ function gitLogForFile(root, file, limit = 50) {
|
|
|
46
69
|
}
|
|
47
70
|
|
|
48
71
|
function gitShow(root, sha, file) {
|
|
72
|
+
if (typeof sha !== 'string' || !SHA_RE.test(sha)) return null;
|
|
49
73
|
try {
|
|
50
|
-
|
|
74
|
+
// `./` makes git resolve `file` relative to cwd (root) — same convention
|
|
75
|
+
// as provenance/git-evidence.js's getBlobAtCommit, and load-bearing for
|
|
76
|
+
// the same reason: it also keeps a `file` that happens to start with
|
|
77
|
+
// `-` from being parsed as a flag once concatenated onto `sha:`.
|
|
78
|
+
// `--no-textconv`: this is the blob-cat form of `show` (`<sha>:<path>`,
|
|
79
|
+
// not a diff) — VERIFIED not reachable via a hostile textconv driver in
|
|
80
|
+
// current git (same as git-evidence.js's getBlobAtCommit), kept for
|
|
81
|
+
// defense-in-depth/uniformity.
|
|
82
|
+
return execFileSync('git', hardenGitArgs(['show', '--no-textconv', `${sha}:./${file}`]), { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: hardenGitEnv() });
|
|
51
83
|
} catch { return null; }
|
|
52
84
|
}
|
|
53
85
|
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
import { loadFramework, evaluateFramework } from './auditor-walkthrough.js';
|
|
38
38
|
import { statePath, safeWriteState } from './state-dir.js';
|
|
39
39
|
import { EVIDENCE_GRADE_DISCLAIMER_SHORT } from './evidence-grade-wording.js';
|
|
40
|
+
import { PROVENANCE_COMPLIANCE_DISCLAIMER } from './provenance/schema.js';
|
|
40
41
|
|
|
41
42
|
export const PRIVACY_FRAMEWORK_ID = 'nist-privacy-1-1';
|
|
42
43
|
|
|
@@ -209,6 +210,19 @@ export function assessPrivacyFramework(scanRoot, scan, opts = {}) {
|
|
|
209
210
|
parser: 'COMPLIANCE',
|
|
210
211
|
family: 'privacy-compliance',
|
|
211
212
|
complianceControl: { framework: PRIVACY_FRAMEWORK_ID, id: c.id, codeTestable: c.codeTestable || 'no' },
|
|
213
|
+
controlRefs: r.controlRefs || [],
|
|
214
|
+
// PRD Section 8 REQUIRED DISCLAIMER. `derivedProvenance` carries an
|
|
215
|
+
// origin commit, author and confidence onto a COMPLIANCE claim, and
|
|
216
|
+
// `compliance --privacy --format json` prints this object straight to
|
|
217
|
+
// stdout -- a second human-facing surface for the same data the auditor
|
|
218
|
+
// walkthrough disclaims inline. Found by a review of the walkthrough
|
|
219
|
+
// fix, which had asserted renderWalkthrough was the only consumer. The
|
|
220
|
+
// disclaimer travels ON the record rather than beside it, because a
|
|
221
|
+
// JSON consumer can slice a single finding out of the array and the
|
|
222
|
+
// caveat has to survive that.
|
|
223
|
+
derivedProvenance: r.derivedProvenance
|
|
224
|
+
? { ...r.derivedProvenance, disclaimer: PROVENANCE_COMPLIANCE_DISCLAIMER }
|
|
225
|
+
: null,
|
|
212
226
|
});
|
|
213
227
|
}
|
|
214
228
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// AI-authorship verification hook (Finding Provenance PRD, M4 §4.3).
|
|
2
|
+
//
|
|
3
|
+
// No concrete external signed-commit-metadata standard exists yet to target
|
|
4
|
+
// (the spec's own words) — so this is an extensible REGISTRY a future
|
|
5
|
+
// verifier plugs into, not a hardcoded vendor integration. With nothing
|
|
6
|
+
// registered (today's real state, and likely for some time), every
|
|
7
|
+
// finding's aiAuthorship stays `unknown` — matching the PRD's explicit
|
|
8
|
+
// default: "Unknown unless signed, verifiable generation metadata exists."
|
|
9
|
+
//
|
|
10
|
+
// Scoped to SAST findingOrigin only (see this plan's own scope-correction
|
|
11
|
+
// note) — a transitive/direct SCA origin is a manifest edit, not source
|
|
12
|
+
// authorship in the sense this hook asks about.
|
|
13
|
+
//
|
|
14
|
+
// KNOWN LIMITATION: `commitMeta` (git-evidence.js) is the only shape any
|
|
15
|
+
// verifier registered here ever receives — `{commit, authorName,
|
|
16
|
+
// authorEmail, authorDate, committerDate, summary}`, where `summary` is
|
|
17
|
+
// `%s` (the subject line only), NOT the full commit message body (`%B`).
|
|
18
|
+
// A future verifier that needs to inspect a trailer buried in the body
|
|
19
|
+
// (e.g. a "Co-Authored-By" line that isn't also the subject) cannot see it
|
|
20
|
+
// through this shape today. `commitMeta` is a widely-used shared primitive
|
|
21
|
+
// elsewhere in this directory, so widening it is a bigger, more careful
|
|
22
|
+
// change than this task's budget — deliberately left as a known limitation
|
|
23
|
+
// rather than silently expanding it here.
|
|
24
|
+
|
|
25
|
+
const _verifiers = new Map();
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Register a verifier. `verifyFn(commitMeta) -> {status, verifier} | null`.
|
|
29
|
+
* A later registration under the SAME name replaces the earlier one (a
|
|
30
|
+
* re-register, not a stack) — matches this codebase's own precedent
|
|
31
|
+
* elsewhere (verification-separation.js's "one verifier, one vote per lens,
|
|
32
|
+
* a re-vote replaces rather than stuffs").
|
|
33
|
+
*/
|
|
34
|
+
export function registerAIAuthorshipVerifier(name, verifyFn) {
|
|
35
|
+
if (typeof name !== 'string' || !name || typeof verifyFn !== 'function') return false;
|
|
36
|
+
_verifiers.set(name, verifyFn);
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Test/reset helper — never called from production code. */
|
|
41
|
+
export function _clearAIAuthorshipVerifiers() {
|
|
42
|
+
_verifiers.clear();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Consults every registered verifier in registration order; the first one
|
|
47
|
+
* to return a non-null result wins (first-registered-first-consulted, not
|
|
48
|
+
* "last wins" — an explicit choice: a more specific verifier should be
|
|
49
|
+
* registered first if precedence matters, rather than this function
|
|
50
|
+
* guessing which of several opinions to prefer). Defaults to
|
|
51
|
+
* {status:'unknown', verifier:null} with nothing registered or every
|
|
52
|
+
* verifier declining to answer.
|
|
53
|
+
*
|
|
54
|
+
* NEVER THROWS: a verifier that throws is treated as "no opinion", exactly
|
|
55
|
+
* like predicate-replay.js and missing-control-resolver.js already treat a
|
|
56
|
+
* throwing caller-supplied function elsewhere in this directory.
|
|
57
|
+
*/
|
|
58
|
+
export function resolveAIAuthorship(commitMeta) {
|
|
59
|
+
if (!commitMeta) return { status: 'unknown', verifier: null };
|
|
60
|
+
for (const [name, verifyFn] of _verifiers) {
|
|
61
|
+
let result;
|
|
62
|
+
try { result = verifyFn(commitMeta); } catch { continue; }
|
|
63
|
+
if (result && typeof result === 'object' && result.status) {
|
|
64
|
+
return { status: result.status, verifier: result.verifier || name };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return { status: 'unknown', verifier: null };
|
|
68
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Branch-introduction resolver (Finding Provenance PRD, Scenario D).
|
|
2
|
+
//
|
|
3
|
+
// Given a finding's already-resolved origin commit, answers "where did this
|
|
4
|
+
// land on the target branch" — either the origin commit itself (it was
|
|
5
|
+
// committed directly on the branch) or the merge commit that brought it in
|
|
6
|
+
// from a feature branch (`git merge --no-ff`). This is a second, distinct
|
|
7
|
+
// question from origin-resolver.js's "which commit introduced this": a
|
|
8
|
+
// commit can be the true origin and still never have existed on the branch
|
|
9
|
+
// people actually deploy from until a later merge folded it in.
|
|
10
|
+
//
|
|
11
|
+
// Same input-validation posture as git-evidence.js, and for the same reason:
|
|
12
|
+
// `originCommit`/`targetRef` are attacker-influenceable-shaped strings
|
|
13
|
+
// (finding data, CLI args) flowing into `execFileSync('git', [...])`. A
|
|
14
|
+
// value like `--output=/tmp/pwned` must never reach argv as a bare token —
|
|
15
|
+
// see git-evidence.js's SHA_RE/SINCE_RE comments for the incident this
|
|
16
|
+
// guards against (a nominally read-only git call gaining write side effects
|
|
17
|
+
// via flag injection).
|
|
18
|
+
|
|
19
|
+
import * as cp from 'node:child_process';
|
|
20
|
+
import { hardenGitArgs, hardenGitEnv } from '../../util/git-hardening.js';
|
|
21
|
+
|
|
22
|
+
const GIT_TIMEOUT_MS = 2000;
|
|
23
|
+
|
|
24
|
+
// Same hostile-repo hardening as git-evidence.js's `_run`, and for the same
|
|
25
|
+
// reason (FR-PROV-024 / second audit) — this module reimplements its own
|
|
26
|
+
// `_run` rather than importing git-evidence.js's, but that must not mean
|
|
27
|
+
// skipping the config/env hardening every other resolver in this directory
|
|
28
|
+
// gets. `--no-textconv` isn't blanket-applied here: this module's own
|
|
29
|
+
// `_run` calls are `rev-parse`/`merge-base`/`log --format=%H` (no diff/blob
|
|
30
|
+
// CONTENT rendered), so there's no textconv surface to close — see
|
|
31
|
+
// git-evidence.js for the calls where `--no-textconv` is load-bearing.
|
|
32
|
+
function _run(scanRoot, args) {
|
|
33
|
+
try {
|
|
34
|
+
const stdout = cp.execFileSync('git', hardenGitArgs(args), {
|
|
35
|
+
cwd: scanRoot, encoding: 'utf8', timeout: GIT_TIMEOUT_MS,
|
|
36
|
+
stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 16 * 1024 * 1024,
|
|
37
|
+
env: hardenGitEnv(),
|
|
38
|
+
});
|
|
39
|
+
return { ok: true, stdout };
|
|
40
|
+
} catch (e) {
|
|
41
|
+
return { ok: false, stdout: '' };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Git SHAs (full or abbreviated) are always lowercase/uppercase hex, 4-40
|
|
46
|
+
// chars. Rejecting anything else closes off flag injection.
|
|
47
|
+
const SHA_RE = /^[0-9a-f]{4,40}$/i;
|
|
48
|
+
function _isSha(sha) {
|
|
49
|
+
return typeof sha === 'string' && SHA_RE.test(sha);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// `targetRef` is used as a revision (both standalone and as the right side
|
|
53
|
+
// of a `originCommit..targetRef` range), so it must look like a ref/branch/
|
|
54
|
+
// sha — never start with `-` (which git would parse as an option) and
|
|
55
|
+
// contain only characters refs can hold.
|
|
56
|
+
const REF_RE = /^[A-Za-z0-9._/-]+$/;
|
|
57
|
+
function _isSafeRef(ref) {
|
|
58
|
+
return typeof ref === 'string' && ref.length > 0 && !ref.startsWith('-') && REF_RE.test(ref);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function _currentBranchName(scanRoot, targetRef) {
|
|
62
|
+
const r = _run(scanRoot, ['rev-parse', '--abbrev-ref', targetRef]);
|
|
63
|
+
return r.ok ? r.stdout.trim() : targetRef;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function resolveBranchEntry(scanRoot, originCommit, targetRef = 'HEAD') {
|
|
67
|
+
if (!_isSha(originCommit)) return null;
|
|
68
|
+
if (!_isSafeRef(targetRef)) return null;
|
|
69
|
+
|
|
70
|
+
const reachable = _run(scanRoot, ['merge-base', '--is-ancestor', originCommit, targetRef]);
|
|
71
|
+
if (!reachable.ok) return null;
|
|
72
|
+
|
|
73
|
+
const branchName = _currentBranchName(scanRoot, targetRef);
|
|
74
|
+
const r = _run(scanRoot, ['log', '--merges', '--ancestry-path', '--reverse', '--format=%H', `${originCommit}..${targetRef}`]);
|
|
75
|
+
if (r.ok && r.stdout.trim()) {
|
|
76
|
+
const firstMerge = r.stdout.trim().split('\n')[0];
|
|
77
|
+
return { commit: firstMerge, ref: `refs/heads/${branchName}`, relationship: 'merge' };
|
|
78
|
+
}
|
|
79
|
+
return { commit: originCommit, ref: `refs/heads/${branchName}`, relationship: 'direct' };
|
|
80
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Content-addressed provenance cache.
|
|
2
|
+
//
|
|
3
|
+
// Paths and writes both go through posture/state-dir.js rather than joining
|
|
4
|
+
// the state directory name by hand. That seam is what enforces the two
|
|
5
|
+
// invariants this module would otherwise each have to remember: it refuses to
|
|
6
|
+
// create state outside a project root, and it honours the read-only scan
|
|
7
|
+
// switch (`--no-state` / AGENTIC_SECURITY_NO_STATE), so scanning somebody
|
|
8
|
+
// else's tree leaves it byte-identical. See test/no-stray-state.test.js — a
|
|
9
|
+
// cache that quietly writes during a read-only scan is exactly the litter that
|
|
10
|
+
// guard exists to prevent.
|
|
11
|
+
//
|
|
12
|
+
// PRIVACY AT REST (second independent Finding Provenance PRD audit): this
|
|
13
|
+
// cache deliberately stores the FULL provenance record — including raw
|
|
14
|
+
// `authorEmail` — not a pre-redacted one. That is a considered choice, not an
|
|
15
|
+
// oversight: `redactFindingProvenance` is applied per output boundary
|
|
16
|
+
// (report/index.js, mcp/tools.js), and different callers in the SAME scan
|
|
17
|
+
// legitimately want different presentations of the SAME cached record — one
|
|
18
|
+
// reader wants the default-redacted view, another passes
|
|
19
|
+
// `--include-author-email`, a third wants `--pseudonymize-authors`. If the
|
|
20
|
+
// cache stored an already-redacted record, whichever policy was in effect at
|
|
21
|
+
// WRITE time would win for every reader forever, silently breaking that
|
|
22
|
+
// per-call flexibility. So redaction stays a read-time/output-time concern,
|
|
23
|
+
// exactly as documented in posture/CLAUDE.md's "Privacy" section, and this
|
|
24
|
+
// cache is accepted as an at-rest store of raw personal data.
|
|
25
|
+
//
|
|
26
|
+
// The mitigation applied here is a permissions floor, not encryption: every
|
|
27
|
+
// write tightens the `provenance-cache/` directory to 0700 and the entry file
|
|
28
|
+
// to 0600 (same posture this project already uses for the per-install HMAC
|
|
29
|
+
// key at integrity.js's `scan-key`, mode 0600 / 0700 parent). That defeats
|
|
30
|
+
// "any local user/process can read this," which is the realistic at-rest
|
|
31
|
+
// threat for a developer machine or CI runner; it does NOT defeat an attacker
|
|
32
|
+
// with root or the operating-system user's own privileges — no local file
|
|
33
|
+
// permission ever does. Encryption-at-rest with a per-install key (the same
|
|
34
|
+
// pattern as `scan-key`) was considered and rejected for this task's scope:
|
|
35
|
+
// unlike the HMAC key, which only ever needs to reproduce a symmetric digest,
|
|
36
|
+
// a cache that must serve back the exact original record on every read would
|
|
37
|
+
// need the plaintext decrypted on every `cacheGet`, which does not raise the
|
|
38
|
+
// bar much over a permissions floor while adding real complexity (key
|
|
39
|
+
// rotation, corrupt-ciphertext handling) for a cache that is disposable and
|
|
40
|
+
// content-addressed to begin with. If that tradeoff is revisited, encrypting
|
|
41
|
+
// only `findingOrigin.authorEmail` (not the whole record) would preserve this
|
|
42
|
+
// module's byte-identical round-trip property, which is asserted by this
|
|
43
|
+
// file's own tests.
|
|
44
|
+
|
|
45
|
+
import * as fs from 'node:fs';
|
|
46
|
+
import * as path from 'node:path';
|
|
47
|
+
import * as crypto from 'node:crypto';
|
|
48
|
+
import { statePath, safeWriteState } from '../state-dir.js';
|
|
49
|
+
import { FINDING_PROVENANCE_SCHEMA_VERSION } from './schema.js';
|
|
50
|
+
|
|
51
|
+
// Permission floor for the cache directory and every entry inside it — see
|
|
52
|
+
// the module header. Applied on every write, not just at directory creation.
|
|
53
|
+
//
|
|
54
|
+
// Precisely what that does and does not retrofit, because the distinction
|
|
55
|
+
// matters and an earlier version of this comment overstated it: ANY write to
|
|
56
|
+
// this directory retightens the DIRECTORY to 0700, including a write for some
|
|
57
|
+
// unrelated key. An individual ENTRY's own mode is only rewritten when that
|
|
58
|
+
// exact content-addressed key is written again — which a cache hit
|
|
59
|
+
// short-circuits, so in practice a pre-existing 0644 entry keeps that mode.
|
|
60
|
+
// It is still unreachable by another local user, because Unix requires
|
|
61
|
+
// traversal (x) permission on every parent directory to open a file by path,
|
|
62
|
+
// and the 0700 directory denies exactly that. The directory mode is what
|
|
63
|
+
// carries the protection; the file mode is defense in depth.
|
|
64
|
+
const CACHE_DIR_MODE = 0o700;
|
|
65
|
+
const CACHE_FILE_MODE = 0o600;
|
|
66
|
+
|
|
67
|
+
// Its own top-level `.agentic-security/provenance-cache/` directory, NOT
|
|
68
|
+
// nested under `provenance/` (where it lived through M0-M4) — the artifact
|
|
69
|
+
// registry (posture/artifact-registry.js) can only apply retention per
|
|
70
|
+
// TOP-LEVEL directory name, and this cache (pure HEAD-keyed memo, safely
|
|
71
|
+
// regenerable) needs a TTL that the provenance/ lifecycle ledger (permanent
|
|
72
|
+
// history) must never get. See PRD Section 8 / artifact-registry.js's
|
|
73
|
+
// 'provenance-cache' entry. Single helper so the read and write paths can
|
|
74
|
+
// never drift apart.
|
|
75
|
+
function keyPath(scanRoot, key) {
|
|
76
|
+
const hash = crypto.createHash('sha256').update(key).digest('hex');
|
|
77
|
+
return statePath(scanRoot, 'provenance-cache', hash + '.json');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The schema version is part of the key, and is added HERE rather than by the
|
|
82
|
+
* caller so that no caller can forget it.
|
|
83
|
+
*
|
|
84
|
+
* `validate.js` rejects a provenance object stamped with a version this build
|
|
85
|
+
* does not understand — but a cache hit never reached that check: `cacheGet`
|
|
86
|
+
* returns the parsed object as-is. With the version outside the key, entries
|
|
87
|
+
* written by an older schema stayed live key hits after a version bump and
|
|
88
|
+
* flowed straight through, defeating the exact scenario the version field was
|
|
89
|
+
* added to guard. Including it means a bump silently misses every stale entry
|
|
90
|
+
* instead, which is the correct outcome: they are recomputed, not trusted.
|
|
91
|
+
*
|
|
92
|
+
* `lineageKey` (M4 §4.2 final-review fix) covers the resolved
|
|
93
|
+
* `.agentic-security/repo-lineage.json` cross-repo link the same way
|
|
94
|
+
* `historyBoundary` already covers `--provenance-since`: a cross-repo
|
|
95
|
+
* `partial` result IS cacheable, so without this the cache key had no field
|
|
96
|
+
* reflecting which (if any) lineage link produced it. Adding, removing, or
|
|
97
|
+
* repointing the declaration at the same HEAD would then keep serving a
|
|
98
|
+
* stale pre-lineage or a stale cross-repo answer. Callers pass the resolved
|
|
99
|
+
* link's own `${path}@${atCommit}`, or the literal `'none'` when
|
|
100
|
+
* `loadRepoLineage` returns nothing — never omit it in a way that collapses
|
|
101
|
+
* both cases to the same empty string the other fields default to.
|
|
102
|
+
*/
|
|
103
|
+
export function makeCacheKey({ repoHead, stableId, detectorVersion, historyBoundary, mode, lineageKey }) {
|
|
104
|
+
return [
|
|
105
|
+
FINDING_PROVENANCE_SCHEMA_VERSION,
|
|
106
|
+
repoHead || '', stableId || '', detectorVersion || '', historyBoundary || '', mode || '', lineageKey || 'none',
|
|
107
|
+
].join('|');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function cacheGet(scanRoot, key) {
|
|
111
|
+
// Read directly and let the catch handle "missing" — an explicit
|
|
112
|
+
// existsSync() check first is a check-then-use race for no benefit, since
|
|
113
|
+
// the catch already covers every failure mode a stale check would too.
|
|
114
|
+
try {
|
|
115
|
+
return JSON.parse(fs.readFileSync(keyPath(scanRoot, key), 'utf8'));
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function cacheSet(scanRoot, key, value) {
|
|
122
|
+
try {
|
|
123
|
+
// safeWriteState creates the directory, applies the project-root check and
|
|
124
|
+
// returns false (never throws) when the read-only switch is on. A refused
|
|
125
|
+
// write is a cache miss next time, which is correct behaviour, not an error.
|
|
126
|
+
// category:'provenance-cache' lets lsp/server.js keep THIS write alive
|
|
127
|
+
// while every other state write stays suppressed on every save — see
|
|
128
|
+
// state-dir.js's withStateWritesDisabled.
|
|
129
|
+
const fp = keyPath(scanRoot, key);
|
|
130
|
+
const wrote = safeWriteState(fp, JSON.stringify(value), { category: 'provenance-cache' });
|
|
131
|
+
if (wrote) {
|
|
132
|
+
// Permission floor (see module header) — best-effort, applied AFTER a
|
|
133
|
+
// successful write so a chmod failure (e.g. an unsupported filesystem)
|
|
134
|
+
// never turns a real cache write into a reported failure.
|
|
135
|
+
try {
|
|
136
|
+
fs.chmodSync(path.dirname(fp), CACHE_DIR_MODE);
|
|
137
|
+
fs.chmodSync(fp, CACHE_FILE_MODE);
|
|
138
|
+
} catch { /* best-effort — see above */ }
|
|
139
|
+
}
|
|
140
|
+
} catch {
|
|
141
|
+
// best-effort — cache failures must never fail a scan
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { CONFIDENCE_LEVEL } from './schema.js';
|
|
2
|
+
|
|
3
|
+
// `renameAmbiguous`: the one production caller (coordinator.js, on the
|
|
4
|
+
// `status:'complete'` path) never passes this true today — see the comment
|
|
5
|
+
// there for why a genuine rename-ambiguous case cannot reach `complete`
|
|
6
|
+
// under the current resolver architecture, so `reasons.push('rename_ambiguous')`
|
|
7
|
+
// below is currently unreachable from real scans. Left wired rather than
|
|
8
|
+
// removed: a caller that DOES have a real rename signal (e.g. a future
|
|
9
|
+
// `complete`-status path that resolves via a rename-following replay) can
|
|
10
|
+
// pass `renameAmbiguous: true` without this function changing at all, and
|
|
11
|
+
// `test/posture/provenance-confidence.test.js` exercises the branch directly.
|
|
12
|
+
export function assessConfidence({
|
|
13
|
+
parentBoundaryVerified = false, historyComplete = false, detectorCompatible = true,
|
|
14
|
+
renameAmbiguous = false, shallow = false, budgetExhausted = false,
|
|
15
|
+
} = {}) {
|
|
16
|
+
const reasons = [];
|
|
17
|
+
if (budgetExhausted) return { level: CONFIDENCE_LEVEL.UNKNOWN, score: 0, reasons: ['budget_exhausted'] };
|
|
18
|
+
|
|
19
|
+
if (parentBoundaryVerified && historyComplete && detectorCompatible && !renameAmbiguous) {
|
|
20
|
+
reasons.push('parent_absence_verified', 'complete_history');
|
|
21
|
+
return { level: CONFIDENCE_LEVEL.HIGH, score: 0.95, reasons };
|
|
22
|
+
}
|
|
23
|
+
if (detectorCompatible && (!historyComplete || !parentBoundaryVerified) && !shallow) {
|
|
24
|
+
if (!historyComplete) reasons.push('partial_history');
|
|
25
|
+
if (!parentBoundaryVerified) reasons.push('no_parent_to_test');
|
|
26
|
+
return { level: CONFIDENCE_LEVEL.MEDIUM, score: 0.65, reasons };
|
|
27
|
+
}
|
|
28
|
+
if (shallow || renameAmbiguous || !detectorCompatible) {
|
|
29
|
+
if (shallow) reasons.push('shallow_history');
|
|
30
|
+
if (renameAmbiguous) reasons.push('rename_ambiguous');
|
|
31
|
+
if (!detectorCompatible) reasons.push('detector_incompatible');
|
|
32
|
+
return { level: CONFIDENCE_LEVEL.LOW, score: 0.35, reasons };
|
|
33
|
+
}
|
|
34
|
+
reasons.push('no_defensible_origin');
|
|
35
|
+
return { level: CONFIDENCE_LEVEL.UNKNOWN, score: 0, reasons };
|
|
36
|
+
}
|